Skip to content

Use CodeMirror in FileContentsCard instead of SyntaxHighlightedCode #1907

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

Merged
merged 32 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
02da4a3
Squash `isCollapsed/isCollapsible` args handling in `FileContentsCard`
VasylMarchuk Jun 29, 2024
6d01989
Use `CodeMirror` instead of `SyntaxHighlightedCode` in `FileContentsC…
VasylMarchuk Jun 29, 2024
1215d48
Add basic global style tweaks for `CodeMirror` component
VasylMarchuk Jun 29, 2024
07bd979
Add Dark Mode support to `FileContentsCard` component
VasylMarchuk Jun 30, 2024
9cf52cd
Fix ordering CodeMirror's `foldGutter` and symbols used as toggle icons
VasylMarchuk Jun 30, 2024
9d8d6a2
Add a demo route for `FileContentsCard` component
VasylMarchuk Jun 30, 2024
d8a82d0
Fix handling of `onExpand` & `onCollapse` when `isCollapsible` is `fa…
VasylMarchuk Jul 6, 2024
da12382
Better handling of collapse/expand in `FileContentsCard` component
VasylMarchuk Jul 7, 2024
6d6da4b
Don't highlight active line in `FileContentsCard` component
VasylMarchuk Jul 7, 2024
f49eb3e
Remove border from CodeMirror's gutter
VasylMarchuk Jul 7, 2024
4b8a8d6
Remove obsolete `@glint-expect-error`
VasylMarchuk Jul 7, 2024
a3e3d24
Adjust CodeMirror style to match GitHub's
VasylMarchuk Jul 7, 2024
9a0ee6d
Add `@foldGutter` argument to `FileContentsCard` and hide it on Build…
VasylMarchuk Jul 8, 2024
9f2c120
Remove unneccessary classes from fold gutter SVGs
VasylMarchuk Jul 12, 2024
48f4605
Move fold/unfold SVGs into an enum `FoldGutterIcon`
VasylMarchuk Jul 12, 2024
d9b6661
Support passing custom themes to `CodeMirror` as `@theme` argument
VasylMarchuk Jul 12, 2024
fb122ad
Add `codeCraftersLight` and `codeCraftersDark` themes (based on GitHu…
VasylMarchuk Jul 12, 2024
544867b
Use `codeCraftersLight` and `codeCraftersDark` themes in `FileContent…
VasylMarchuk Jul 12, 2024
0655ffb
Add `foldGutter` option to `FileContentsCard` demo page
VasylMarchuk Jul 12, 2024
a7e607b
Extract CodeMirror's page object into a separate tests support file
VasylMarchuk Jul 13, 2024
d35c8ee
Add an acceptance test `course-admin | view-code-example`
VasylMarchuk Jul 13, 2024
49b9ef8
Add `@ember/test-waiters` package to the project
VasylMarchuk Jul 13, 2024
e3a5217
Use `@waitFor` decorator to properly wait for `CodeMirror` rendering/…
VasylMarchuk Jul 13, 2024
498af6a
Replace `border` with `outline` on the CodeMirror demo page
VasylMarchuk Jul 13, 2024
04aab43
Add `flex-wrap` to right-floating option groups on the demo route
VasylMarchuk Jul 13, 2024
0e2af64
Move `codeMirror` page object to `tests/pages/components`
VasylMarchuk Jul 15, 2024
04b0704
Rename `handleCollapseExpandButtonClick` to `handleCollapseOrExpandBu…
VasylMarchuk Jul 16, 2024
96ba1bf
Add proper JSDoc for `FileContentsCard` signature
VasylMarchuk Jul 16, 2024
846a0ed
Properly disable `headerTooltipText` option when `isCollapsible` is f…
VasylMarchuk Jul 16, 2024
26cc64e
Insert a blank line between members
VasylMarchuk Jul 16, 2024
ec38ee0
Remove built-in themes from `CodeMirror`, allow passing only custom o…
VasylMarchuk Jul 16, 2024
7e0ccab
Use a map instead of using a switch (to make CodeRabbit happier)
VasylMarchuk Jul 16, 2024
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
44 changes: 30 additions & 14 deletions app/components/code-mirror.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { waitFor } from '@ember/test-waiters';

import {
EditorView,
Expand Down Expand Up @@ -37,16 +38,19 @@ import {
import { languages } from '@codemirror/language-data';
import { markdown } from '@codemirror/lang-markdown';

import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
function generateHTMLElement(src: string): HTMLElement {
const div = document.createElement('div');
div.innerHTML = src;

const THEME_EXTENSIONS: {
[key: string]: Extension;
} = {
githubDark,
githubLight,
};
return div.firstChild as HTMLElement;
}

enum FoldGutterIcon {
Expanded = '<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible; cursor: pointer;"><path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"></path></svg>',
Collapsed = '<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" style="display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible; cursor: pointer;"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"></path></svg>',
}

type Argument = boolean | string | number | ((newValue: string) => void) | undefined;
type Argument = boolean | string | number | Extension | ((newValue: string) => void) | undefined;

export interface Signature {
Element: Element;
Expand Down Expand Up @@ -93,7 +97,7 @@ export interface Signature {
/**
* Theme to use for the editor
*/
theme?: string;
theme?: Extension;
/**
* Allow multiple selections by using CTRL/CMD key
*/
Expand Down Expand Up @@ -230,7 +234,7 @@ export interface OptionHandlersSignature {
scrollPastEnd: (enabled?: boolean) => Extension[];
syntaxHighlighting: (enabled?: boolean) => Extension[];
tabSize: (tabSize?: number) => Extension[];
theme: (theme?: string) => Extension[];
theme: (theme?: Extension) => Extension[];
languageOrFilename: (newValue: string | undefined, args: Signature['Args']['Named'], changedOptionName?: string) => Promise<Extension[]>;
originalDocumentOrMergeControls: (
newValue: string | boolean | undefined,
Expand All @@ -248,7 +252,6 @@ const OPTION_HANDLERS: OptionHandlersSignature = {
drawSelection: (enabled) => (enabled ? [drawSelection()] : []),
dropCursor: (enabled) => (enabled ? [dropCursor()] : []),
editable: (enabled) => [EditorView.editable.of(!!enabled)],
foldGutter: (enabled) => (enabled ? [foldGutter(), keymap.of(foldKeymap)] : []),
highlightActiveLine: (enabled) => (enabled ? [highlightActiveLine(), highlightActiveLineGutter()] : []),
highlightSelectionMatches: (enabled) => (enabled ? [highlightSelectionMatches()] : []),
highlightSpecialChars: (enabled) => (enabled ? [highlightSpecialChars()] : []),
Expand All @@ -259,6 +262,15 @@ const OPTION_HANDLERS: OptionHandlersSignature = {
indentUnit: (indentUnitText) => (indentUnitText !== undefined ? [indentUnit.of(indentUnitText)] : []),
indentWithTab: (enabled) => (enabled ? [keymap.of([indentWithTab])] : []),
lineNumbers: (enabled) => (enabled ? [lineNumbers()] : []),
foldGutter: (enabled) =>
enabled
? [
foldGutter({
markerDOM: (open) => generateHTMLElement(open ? FoldGutterIcon.Expanded : FoldGutterIcon.Collapsed),
}),
keymap.of(foldKeymap),
]
: [],
lineSeparator: (lineSeparatorText) => (lineSeparatorText !== undefined ? [EditorState.lineSeparator.of(lineSeparatorText)] : []),
lineWrapping: (enabled) => (enabled ? [EditorView.lineWrapping] : []),
placeholder: (placeholderText) => (placeholderText !== undefined ? [placeholder(placeholderText)] : []),
Expand All @@ -267,7 +279,7 @@ const OPTION_HANDLERS: OptionHandlersSignature = {
scrollPastEnd: (enabled) => (enabled ? [scrollPastEnd()] : []),
syntaxHighlighting: (enabled) => (enabled ? [syntaxHighlighting(defaultHighlightStyle, { fallback: true })] : []),
tabSize: (tabSize) => (tabSize !== undefined ? [EditorState.tabSize.of(tabSize)] : []),
theme: (theme) => (theme !== undefined ? [THEME_EXTENSIONS[theme] || []] : []),
theme: (theme) => (theme !== undefined ? [theme] : []),
languageOrFilename: async (_newValue, { language, filename }) => {
const detectedLanguage = language
? LanguageDescription.matchLanguageName(languages, language)
Expand Down Expand Up @@ -335,7 +347,9 @@ export default class CodeMirrorComponent extends Component<Signature> {
);
}

@action async optionDidChange(optionName: string, _element: Element, [newValue]: [boolean | string | number | undefined]) {
@action
@waitFor
async optionDidChange(optionName: string, _element: Element, [newValue]: [boolean | string | number | Extension | undefined]) {
const compartment = this.compartments.get(optionName);
const handlerMethod = OPTION_HANDLERS[optionName];

Expand Down Expand Up @@ -364,7 +378,9 @@ export default class CodeMirrorComponent extends Component<Signature> {
}
}

@action async renderEditor(element: Element) {
@action
@waitFor
async renderEditor(element: Element) {
this.renderedView = new EditorView({
parent: element,
doc: this.args.document,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
{{/if}}

{{#each this.changedFilesForRender key="filename" as |changedFile index|}}
<div class="{{if (not-eq index (sub @solution.changedFiles.length 1)) 'mb-4'}} bg-white shadow-sm rounded border border-gray-300">
<div
class="{{if (not-eq index (sub @solution.changedFiles.length 1)) 'mb-4'}} bg-white shadow-sm rounded border border-gray-300"
data-test-community-solution-changed-file
>
<div class="bg-gray-100 rounded-t py-2 px-4 border-b border-gray-200 shadow-sm flex items-center justify-between sticky top-10 z-10">
<span class="font-mono text-xs text-gray-600 bold">{{changedFile.filename}}</span>
<div>
Expand Down Expand Up @@ -60,7 +63,7 @@
@filename={{unchangedFileComparison.path}}
@code={{unchangedFileComparison.content}}
@language={{@solution.language.slug}}
data-test-unchanged-file-contents-card
data-test-community-solution-unchanged-file
{{! scroll-mt-16 accounts for the sticky menu bar}}
class="mt-4 text-sm scroll-mt-16"
>
Expand Down
49 changes: 35 additions & 14 deletions app/components/file-contents-card.hbs
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
<div class="bg-white shadow-sm rounded border border-gray-300" ...attributes {{did-insert this.handleDidInsertContainer}}>
<div
class="bg-white dark:bg-gray-950 shadow-sm rounded border border-gray-300 dark:border-gray-800"
...attributes
{{did-insert this.handleDidInsertContainer}}
>
{{! template-lint-disable no-invalid-interactive }}
<div
class="flex items-center bg-gray-100 h-8 px-4 relative group/file-contents-card-header
{{if @isCollapsed 'rounded' 'rounded-t'}}
{{if @isCollapsible 'hover:bg-gray-50' ''}}
{{if @isCollapsible 'cursor-pointer' 'cursor-default'}}
{{if @isCollapsed 'border-b-0' 'border-b border-gray-200'}}"
class="flex items-center bg-gray-100 dark:bg-gray-900 h-8 px-4 relative group/file-contents-card-header
{{if @isCollapsed 'rounded border-b-0' 'rounded-t border-b border-gray-200 dark:border-gray-800'}}
{{if @isCollapsible 'cursor-pointer hover:bg-gray-50' 'cursor-default'}}"
role={{if @isCollapsible "button"}}
{{! @glint-expect-error doesn't allow mouse event since it has args }}
{{on "click" (if @isCollapsed @onExpand this.handleCollapseButtonClick)}}
data-test-file-contents-card-header
{{on "click" this.handleCollapseOrExpandButtonClick}}
>
{{#if (has-block "header")}}
{{yield to="header"}}
{{else}}
<span class="font-mono text-xs text-gray-600 bold flex">
<span class="font-mono text-xs text-gray-600 dark:text-gray-100 bold flex">
{{@filename}}
</span>
{{/if}}
Expand All @@ -27,18 +29,37 @@
{{/if}}
</div>

{{#if @headerTooltipText}}
{{#if (and @isCollapsed @headerTooltipText)}}
<EmberTooltip @text={{@headerTooltipText}} @side="top" />
{{/if}}
{{/if}}
</div>

{{#unless @isCollapsed}}
<SyntaxHighlightedCode @code={{@code}} @language={{@language}} @theme="github-light" @shouldDisplayLineNumbers={{true}} class="text-sm" />
{{#unless (and @isCollapsible @isCollapsed)}}
<CodeMirror
@document={{@code}}
@filename={{@filename}}
@language={{@language}}
@lineNumbers={{true}}
@allowMultipleSelections={{true}}
@bracketMatching={{true}}
@crosshairCursor={{true}}
@drawSelection={{true}}
@rectangularSelection={{true}}
@editable={{false}}
@readOnly={{true}}
@foldGutter={{if (eq @foldGutter false) false true}}
@highlightActiveLine={{false}}
@highlightSelectionMatches={{true}}
@highlightSpecialChars={{true}}
@highlightTrailingWhitespace={{true}}
@theme={{this.codeMirrorTheme}}
class="block text-sm"
/>

{{#if @isCollapsible}}
<div class="flex items-center justify-center mb-3">
<TertiaryButton @size="small" {{on "click" this.handleCollapseButtonClick}}>
<div class="flex items-center justify-center my-3">
<TertiaryButton @size="small" {{on "click" this.handleCollapseOrExpandButtonClick}}>
Collapse File
</TertiaryButton>
</div>
Expand Down
68 changes: 62 additions & 6 deletions app/components/file-contents-card.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,90 @@
import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import type DarkModeService from 'codecrafters-frontend/services/dark-mode';
import { codeCraftersDark, codeCraftersLight } from 'codecrafters-frontend/utils/code-mirror-themes';

interface Signature {
Element: HTMLDivElement;

Args: {
/**
* Code to render in CodeMirror
*/
code: string;
language: string;
/**
* Filename to render in the header.
* Also used to auto-detect language for code formatting
*/
filename: string;
headerTooltipText?: string; // If collapsible, use this to provide a tooltip for the header
/**
* Override language auto-detected from `filename` and set it manually
*/
language: string;
/**
* Enable code folding and fold gutter in CodeMirror
*/
foldGutter?: boolean;
/**
* Enable collapsing of the file card to just the header
*/
isCollapsible?: boolean;
/**
* Should the card be currently collapsed
*/
isCollapsed?: boolean;
/**
* Show a tooltip in the header when collapsible & collapsed
*/
headerTooltipText?: string;
/**
* Scroll the component into view after it's collapsed
*/
scrollIntoViewOnCollapse?: boolean;
/**
* Callback to call when Expand button is clicked
*/
onExpand?: () => void;
/**
* Callback to call when Collapse button is clicked
*/
onCollapse?: () => void;
};

Blocks: {
/**
* Allows rendering custom content in the header
*/
header?: [];
};
}

export default class FileContentsCardComponent extends Component<Signature> {
@service declare darkMode: DarkModeService;

@tracked containerElement: HTMLDivElement | null = null;

get codeMirrorTheme() {
return this.darkMode.isEnabled ? codeCraftersDark : codeCraftersLight;
}

@action
handleCollapseButtonClick() {
if (this.args.onCollapse) {
this.args.onCollapse();
this.containerElement!.scrollIntoView({ behavior: 'smooth' });
handleCollapseOrExpandButtonClick() {
if (!this.args.isCollapsible) {
return;
}

Check warning on line 76 in app/components/file-contents-card.ts

View check run for this annotation

Codecov / codecov/patch

app/components/file-contents-card.ts#L76

Added line #L76 was not covered by tests

if (this.args.isCollapsed && this.args.onExpand) {
this.args.onExpand();
} else if (!this.args.isCollapsed) {
if (this.args.onCollapse) {
this.args.onCollapse();
}

Check warning on line 83 in app/components/file-contents-card.ts

View check run for this annotation

Codecov / codecov/patch

app/components/file-contents-card.ts#L83

Added line #L83 was not covered by tests

if (this.args.scrollIntoViewOnCollapse !== false) {
this.containerElement!.scrollIntoView({ behavior: 'smooth' });
}

Check warning on line 87 in app/components/file-contents-card.ts

View check run for this annotation

Codecov / codecov/patch

app/components/file-contents-card.ts#L87

Added line #L87 was not covered by tests
}
}

Expand Down
37 changes: 20 additions & 17 deletions app/controllers/demo/code-mirror.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

import { type Extension } from '@codemirror/state';
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
import { service } from '@ember/service';

import type DarkModeService from 'codecrafters-frontend/services/dark-mode';
import { codeCraftersDark, codeCraftersLight } from 'codecrafters-frontend/utils/code-mirror-themes';

const THEME_EXTENSIONS: {
[key: string]: Extension;
} = {
githubDark,
githubLight,
codeCraftersLight,
codeCraftersDark,
};

const SUPPORTED_THEMES = [...Object.keys(THEME_EXTENSIONS).filter((key) => !(key.startsWith('defaultSettings') || key.endsWith('Init'))), 'Auto'];
const DEFAULT_THEME = 'Auto';
const DEFAULT_THEME_DARK = 'githubDark';
const DEFAULT_THEME_LIGHT = 'githubLight';
const SUPPORTED_THEMES = [...Object.keys(THEME_EXTENSIONS), 'codeCraftersAuto'];
const DEFAULT_THEME = 'codeCraftersAuto';

class ExampleDocument {
@tracked document!: string;
Expand Down Expand Up @@ -111,13 +110,13 @@ export default class DemoCodeMirrorController extends Controller {

@tracked tabSizes = [1, 2, 4, 6, 8, 10, 12, 16];

@tracked themes = SUPPORTED_THEMES;
@tracked themes = [...SUPPORTED_THEMES];

@tracked selectedDocumentIndex: number = 1;
@tracked selectedIndentUnitIndex: number = 1;
@tracked selectedLineSeparatorIndex: number = 1;
@tracked selectedTabSizeIndex: number = 2;
@tracked selectedThemeIndex: number = SUPPORTED_THEMES.indexOf(DEFAULT_THEME);
@tracked selectedThemeIndex: number = this.themes.indexOf(DEFAULT_THEME);

get selectedDocument() {
return this.documents[this.selectedDocumentIndex];
Expand All @@ -138,14 +137,18 @@ export default class DemoCodeMirrorController extends Controller {
get selectedTheme() {
const theme = this.themes[this.selectedThemeIndex];

return theme === 'Auto'
? this.darkMode.isEnabled
? this.themes[this.themes.indexOf(DEFAULT_THEME_DARK)]
: this.themes[this.themes.indexOf(DEFAULT_THEME_LIGHT)]
: theme;
const themeMap: {
[key: string]: Extension;
} = {
codeCraftersLight,
codeCraftersDark,
codeCraftersAuto: this.darkMode.isEnabled ? codeCraftersDark : codeCraftersLight,
};

return theme !== undefined ? themeMap[theme] : undefined;
}

@tracked border: boolean = true;
@tracked outline: boolean = true;

@tracked allowMultipleSelections: boolean = true;
@tracked autocompletion: boolean = true;
Expand Down
Loading
Loading