diff --git a/.prettierignore b/.prettierignore index c9e23ff5ec..df90f61831 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,3 +21,6 @@ # index.html ends up weirdly formatted /app/index.html /tests/index.html + +# code-mirror-gutter-rs is cloned from @codemirror/view +/app/utils/code-mirror-gutter-rs.ts diff --git a/app/components/code-mirror.ts b/app/components/code-mirror.ts index 7e0b59fca8..f6e5f91f1b 100644 --- a/app/components/code-mirror.ts +++ b/app/components/code-mirror.ts @@ -38,6 +38,7 @@ import { languages } from '@codemirror/language-data'; 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'; function generateHTMLElement(src: string): HTMLElement { const div = document.createElement('div'); @@ -66,7 +67,8 @@ const OPTION_HANDLERS: { [key: string]: OptionHandler } = { drawSelection: ({ drawSelection: enabled }) => (enabled ? [drawSelection()] : []), dropCursor: ({ dropCursor: enabled }) => (enabled ? [dropCursor()] : []), editable: ({ editable }) => [EditorView.editable.of(!!editable)], - highlightActiveLine: ({ highlightActiveLine: enabled }) => (enabled ? [highlightActiveLine(), highlightActiveLineGutter()] : []), + highlightActiveLine: ({ highlightActiveLine: enabled }) => + enabled ? [highlightActiveLine(), highlightActiveLineGutter(), highlightActiveLineGutterRS()] : [], highlightNewlines: ({ highlightNewlines: enabled }) => (enabled ? [highlightNewlines()] : []), highlightSelectionMatches: ({ highlightSelectionMatches: enabled }) => (enabled ? [highlightSelectionMatches()] : []), highlightSpecialChars: ({ highlightSpecialChars: enabled }) => (enabled ? [highlightSpecialChars()] : []), diff --git a/app/utils/code-mirror-collapse-unchanged-gutter.ts b/app/utils/code-mirror-collapse-unchanged-gutter.ts index 9a8e39f044..4f65f8cbca 100644 --- a/app/utils/code-mirror-collapse-unchanged-gutter.ts +++ b/app/utils/code-mirror-collapse-unchanged-gutter.ts @@ -1,5 +1,6 @@ import { BlockInfo, EditorView, gutter, GutterMarker, type WidgetType } from '@codemirror/view'; import { uncollapseUnchanged } from '@codemirror/merge'; +import { gutter as gutterRS, GutterMarker as GutterMarkerRS } from 'codecrafters-frontend/utils/code-mirror-gutter-rs'; function isCollapseUnchangedWidget(widget: WidgetType) { return 'type' in widget && widget.type === 'collapsed-unchanged-code'; @@ -43,6 +44,23 @@ export class CollapseUnchangedGutterMarker extends GutterMarker { } } +export class CollapseUnchangedGutterMarkerRS extends GutterMarkerRS { + line: BlockInfo; + view: EditorView; + widget: WidgetType; + + constructor(view: EditorView, widget: WidgetType, line: BlockInfo) { + super(); + this.line = line; + this.view = view; + this.widget = widget; + } + + toDOM(view: EditorView) { + return renderGutterElement(view, this.widget, this.line); + } +} + export function collapseUnchangedGutter() { return [ gutter({ @@ -52,5 +70,13 @@ export function collapseUnchangedGutter() { return isCollapseUnchangedWidget(widget) ? new CollapseUnchangedGutterMarker(view, widget, line) : null; }, }), + + gutterRS({ + class: 'cm-collapseUnchangedGutter', + + widgetMarker(view, widget, line) { + return isCollapseUnchangedWidget(widget) ? new CollapseUnchangedGutterMarkerRS(view, widget, line) : null; + }, + }), ]; } diff --git a/app/utils/code-mirror-gutter-rs.ts b/app/utils/code-mirror-gutter-rs.ts new file mode 100644 index 0000000000..e11ebd8686 --- /dev/null +++ b/app/utils/code-mirror-gutter-rs.ts @@ -0,0 +1,531 @@ +/// +/// Copied from https://github.com/codemirror/view/blob/6.36.2/src/gutter.ts +/// + +// @ts-nocheck +/* eslint-disable */ + +import { combineConfig, MapMode, Facet, type Extension, EditorState, RangeValue, RangeSet, type RangeCursor } from '@codemirror/state'; +import { EditorView, ViewPlugin, ViewUpdate, BlockType, WidgetType, BlockInfo, Direction } from '@codemirror/view'; + +// import {combineConfig, MapMode, Facet, Extension, EditorState, +// RangeValue, RangeSet, RangeCursor} from "@codemirror/state" +// import {EditorView} from "./editorview" +// import {ViewPlugin, ViewUpdate} from "./extension" +// import {BlockType, WidgetType} from "./decoration" +// import {BlockInfo} from "./heightmap" +// import {Direction} from "./bidi" + +/// A gutter marker represents a bit of information attached to a line +/// in a specific gutter. Your own custom markers have to extend this +/// class. +export abstract class GutterMarker extends RangeValue { + /// @internal + compare(other: GutterMarker) { + return this == other || this.constructor == other.constructor && this.eq(other) + } + + /// Compare this marker to another marker of the same type. + eq(other: GutterMarker): boolean { return false } + + /// Render the DOM node for this marker, if any. + toDOM?(view: EditorView): Node + + /// This property can be used to add CSS classes to the gutter + /// element that contains this marker. + elementClass!: string + + /// Called if the marker has a `toDOM` method and its representation + /// was removed from a gutter. + destroy(dom: Node) {} +} + +GutterMarker.prototype.elementClass = "" +GutterMarker.prototype.toDOM = undefined +GutterMarker.prototype.mapMode = MapMode.TrackBefore +GutterMarker.prototype.startSide = GutterMarker.prototype.endSide = -1 +GutterMarker.prototype.point = true + +/// Facet used to add a class to all gutter elements for a given line. +/// Markers given to this facet should _only_ define an +/// [`elementclass`](#view.GutterMarker.elementClass), not a +/// [`toDOM`](#view.GutterMarker.toDOM) (or the marker will appear +/// in all gutters for the line). +export const gutterLineClass = Facet.define>() + +/// Facet used to add a class to all gutter elements next to a widget. +/// Should not provide widgets with a `toDOM` method. +export const gutterWidgetClass = + Facet.define<(view: EditorView, widget: WidgetType, block: BlockInfo) => GutterMarker | null>() + +type Handlers = {[event: string]: (view: EditorView, line: BlockInfo, event: Event) => boolean} + +interface GutterConfig { + /// An extra CSS class to be added to the wrapper (`cm-gutter`) + /// element. + class?: string + /// Controls whether empty gutter elements should be rendered. + /// Defaults to false. + renderEmptyElements?: boolean + /// Retrieve a set of markers to use in this gutter. + markers?: (view: EditorView) => (RangeSet | readonly RangeSet[]) + /// Can be used to optionally add a single marker to every line. + lineMarker?: (view: EditorView, line: BlockInfo, otherMarkers: readonly GutterMarker[]) => GutterMarker | null + /// Associate markers with block widgets in the document. + widgetMarker?: (view: EditorView, widget: WidgetType, block: BlockInfo) => GutterMarker | null + /// If line or widget markers depend on additional state, and should + /// be updated when that changes, pass a predicate here that checks + /// whether a given view update might change the line markers. + lineMarkerChange?: null | ((update: ViewUpdate) => boolean) + /// Add a hidden spacer element that gives the gutter its base + /// width. + initialSpacer?: null | ((view: EditorView) => GutterMarker) + /// Update the spacer element when the view is updated. + updateSpacer?: null | ((spacer: GutterMarker, update: ViewUpdate) => GutterMarker) + /// Supply event handlers for DOM events on this gutter. + domEventHandlers?: Handlers, +} + +const defaults = { + class: "", + renderEmptyElements: false, + elementStyle: "", + markers: () => RangeSet.empty, + lineMarker: () => null, + widgetMarker: () => null, + lineMarkerChange: null, + initialSpacer: null, + updateSpacer: null, + domEventHandlers: {} +} + +const activeGutters = Facet.define>() + +/// Define an editor gutter. The order in which the gutters appear is +/// determined by their extension priority. +export function gutter(config: GutterConfig): Extension { + return [gutters(), activeGutters.of({...defaults, ...config})] +} + +const unfixGutters = Facet.define({ + combine: values => values.some(x => x) +}) + +/// The gutter-drawing plugin is automatically enabled when you add a +/// gutter, but you can use this function to explicitly configure it. +/// +/// Unless `fixed` is explicitly set to `false`, the gutters are +/// fixed, meaning they don't scroll along with the content +/// horizontally (except on Internet Explorer, which doesn't support +/// CSS [`position: +/// sticky`](https://developer.mozilla.org/en-US/docs/Web/CSS/position#sticky)). +export function gutters(config?: {fixed?: boolean}): Extension { + let result: Extension[] = [ + gutterView, + ] + if (config && config.fixed === false) result.push(unfixGutters.of(true)) + return result +} + +const gutterView = ViewPlugin.fromClass(class { + gutters: SingleGutterView[] + dom: HTMLElement + fixed: boolean + prevViewport: {from: number, to: number} + + constructor(readonly view: EditorView) { + this.prevViewport = view.viewport + this.dom = document.createElement("div") + this.dom.className = "cm-gutters cm-gutters-rs" + this.dom.setAttribute("aria-hidden", "true") + this.dom.style.minHeight = (this.view.contentHeight / this.view.scaleY) + "px" + this.dom.style.right = "0"; + this.gutters = view.state.facet(activeGutters).map(conf => new SingleGutterView(view, conf)) + for (let gutter of this.gutters) this.dom.appendChild(gutter.dom) + this.fixed = !view.state.facet(unfixGutters) + if (this.fixed) { + // FIXME IE11 fallback, which doesn't support position: sticky, + // by using position: relative + event handlers that realign the + // gutter (or just force fixed=false on IE11?) + this.dom.style.position = "sticky" + } + this.syncGutters(false) + view.scrollDOM.insertBefore(this.dom, view.contentDOM.nextSibling) + } + + update(update: ViewUpdate) { + if (this.updateGutters(update)) { + // Detach during sync when the viewport changed significantly + // (such as during scrolling), since for large updates that is + // faster. + let vpA = this.prevViewport, vpB = update.view.viewport + let vpOverlap = Math.min(vpA.to, vpB.to) - Math.max(vpA.from, vpB.from) + this.syncGutters(vpOverlap < (vpB.to - vpB.from) * 0.8) + } + if (update.geometryChanged) { + this.dom.style.minHeight = (this.view.contentHeight / this.view.scaleY) + "px" + } + if (this.view.state.facet(unfixGutters) != !this.fixed) { + this.fixed = !this.fixed + this.dom.style.position = this.fixed ? "sticky" : "" + } + this.prevViewport = update.view.viewport + } + + syncGutters(detach: boolean) { + let after = this.dom.nextSibling + if (detach) this.dom.remove() + let lineClasses = RangeSet.iter(this.view.state.facet(gutterLineClass), this.view.viewport.from) + let classSet: GutterMarker[] = [] + let contexts = this.gutters.map(gutter => new UpdateContext(gutter, this.view.viewport, -this.view.documentPadding.top)) + for (let line of this.view.viewportLineBlocks) { + if (classSet.length) classSet = [] + if (Array.isArray(line.type)) { + let first = true + for (let b of line.type) { + if (b.type == BlockType.Text && first) { + advanceCursor(lineClasses, classSet, b.from) + for (let cx of contexts) cx.line(this.view, b, classSet) + first = false + } else if (b.widget) { + for (let cx of contexts) cx.widget(this.view, b) + } + } + } else if (line.type == BlockType.Text) { + advanceCursor(lineClasses, classSet, line.from) + for (let cx of contexts) cx.line(this.view, line, classSet) + } else if (line.widget) { + for (let cx of contexts) cx.widget(this.view, line) + } + } + for (let cx of contexts) cx.finish() + if (detach) { + if (after) { + this.view.scrollDOM.insertBefore(this.dom, after) + } else { + this.view.scrollDOM.appendChild(this.dom) + } + } + } + + updateGutters(update: ViewUpdate) { + let prev = update.startState.facet(activeGutters), cur = update.state.facet(activeGutters) + let change = update.docChanged || update.heightChanged || update.viewportChanged || + !RangeSet.eq(update.startState.facet(gutterLineClass), update.state.facet(gutterLineClass), + update.view.viewport.from, update.view.viewport.to) + if (prev == cur) { + for (let gutter of this.gutters) if (gutter.update(update)) change = true + } else { + change = true + let gutters = [] + for (let conf of cur) { + let known = prev.indexOf(conf) + if (known < 0) { + gutters.push(new SingleGutterView(this.view, conf)) + } else { + this.gutters[known].update(update) + gutters.push(this.gutters[known]) + } + } + for (let g of this.gutters) { + g.dom.remove() + if (gutters.indexOf(g) < 0) g.destroy() + } + for (let g of gutters) this.dom.appendChild(g.dom) + this.gutters = gutters + } + return change + } + + destroy() { + for (let view of this.gutters) view.destroy() + this.dom.remove() + } +}, { + provide: plugin => EditorView.scrollMargins.of(view => { + let value = view.plugin(plugin) + if (!value || value.gutters.length == 0 || !value.fixed) return null + return view.textDirection == Direction.LTR + ? {left: value.dom.offsetWidth * view.scaleX} + : {right: value.dom.offsetWidth * view.scaleX} + }) +}) + +function asArray(val: T | readonly T[]) { return (Array.isArray(val) ? val : [val]) as readonly T[] } + +function advanceCursor(cursor: RangeCursor, collect: GutterMarker[], pos: number) { + while (cursor.value && cursor.from <= pos) { + if (cursor.from == pos) collect.push(cursor.value) + cursor.next() + } +} + +class UpdateContext { + cursor: RangeCursor + i = 0 + + constructor(readonly gutter: SingleGutterView, viewport: {from: number, to: number}, public height: number) { + this.cursor = RangeSet.iter(gutter.markers, viewport.from) + } + + addElement(view: EditorView, block: BlockInfo, markers: readonly GutterMarker[]) { + let {gutter} = this, above = (block.top - this.height) / view.scaleY, height = block.height / view.scaleY + if (this.i == gutter.elements.length) { + let newElt = new GutterElement(view, height, above, markers) + gutter.elements.push(newElt) + gutter.dom.appendChild(newElt.dom) + } else { + gutter.elements[this.i].update(view, height, above, markers) + } + this.height = block.bottom + this.i++ + } + + line(view: EditorView, line: BlockInfo, extraMarkers: readonly GutterMarker[]) { + let localMarkers: GutterMarker[] = [] + advanceCursor(this.cursor, localMarkers, line.from) + if (extraMarkers.length) localMarkers = localMarkers.concat(extraMarkers) + let forLine = this.gutter.config.lineMarker(view, line, localMarkers) + if (forLine) localMarkers.unshift(forLine) + + let gutter = this.gutter + if (localMarkers.length == 0 && !gutter.config.renderEmptyElements) return + this.addElement(view, line, localMarkers) + } + + widget(view: EditorView, block: BlockInfo) { + let marker = this.gutter.config.widgetMarker(view, block.widget!, block), markers = marker ? [marker] : null + for (let cls of view.state.facet(gutterWidgetClass)) { + let marker = cls(view, block.widget!, block) + if (marker) (markers || (markers = [])).push(marker) + } + if (markers) this.addElement(view, block, markers) + } + + finish() { + let gutter = this.gutter + while (gutter.elements.length > this.i) { + let last = gutter.elements.pop()! + gutter.dom.removeChild(last.dom) + last.destroy() + } + } +} + +class SingleGutterView { + dom: HTMLElement + elements: GutterElement[] = [] + markers: readonly RangeSet[] + spacer: GutterElement | null = null + + constructor(public view: EditorView, public config: Required) { + this.dom = document.createElement("div") + this.dom.className = "cm-gutter" + (this.config.class ? " " + this.config.class : "") + for (let prop in config.domEventHandlers) { + this.dom.addEventListener(prop, (event: Event) => { + let target = event.target as HTMLElement, y + if (target != this.dom && this.dom.contains(target)) { + while (target.parentNode != this.dom) target = target.parentNode as HTMLElement + let rect = target.getBoundingClientRect() + y = (rect.top + rect.bottom) / 2 + } else { + y = (event as MouseEvent).clientY + } + let line = view.lineBlockAtHeight(y - view.documentTop) + if (config.domEventHandlers[prop](view, line, event)) event.preventDefault() + }) + } + this.markers = asArray(config.markers(view)) + if (config.initialSpacer) { + this.spacer = new GutterElement(view, 0, 0, [config.initialSpacer(view)]) + this.dom.appendChild(this.spacer.dom) + this.spacer.dom.style.cssText += "visibility: hidden; pointer-events: none" + } + } + + update(update: ViewUpdate) { + let prevMarkers = this.markers + this.markers = asArray(this.config.markers(update.view)) + if (this.spacer && this.config.updateSpacer) { + let updated = this.config.updateSpacer(this.spacer.markers[0], update) + if (updated != this.spacer.markers[0]) this.spacer.update(update.view, 0, 0, [updated]) + } + let vp = update.view.viewport + return !RangeSet.eq(this.markers, prevMarkers, vp.from, vp.to) || + (this.config.lineMarkerChange ? this.config.lineMarkerChange(update) : false) + } + + destroy() { + for (let elt of this.elements) elt.destroy() + } +} + +class GutterElement { + dom: HTMLElement + height: number = -1 + above: number = 0 + markers: readonly GutterMarker[] = [] + + constructor(view: EditorView, height: number, above: number, markers: readonly GutterMarker[]) { + this.dom = document.createElement("div") + this.dom.className = "cm-gutterElement" + this.update(view, height, above, markers) + } + + update(view: EditorView, height: number, above: number, markers: readonly GutterMarker[]) { + if (this.height != height) { + this.height = height + this.dom.style.height = height + "px" + } + if (this.above != above) + this.dom.style.marginTop = (this.above = above) ? above + "px" : "" + if (!sameMarkers(this.markers, markers)) this.setMarkers(view, markers) + } + + setMarkers(view: EditorView, markers: readonly GutterMarker[]) { + let cls = "cm-gutterElement", domPos = this.dom.firstChild + for (let iNew = 0, iOld = 0;;) { + let skipTo = iOld, marker = iNew < markers.length ? markers[iNew++] : null, matched = false + if (marker) { + let c = marker.elementClass + if (c) cls += " " + c + for (let i = iOld; i < this.markers.length; i++) + if (this.markers[i].compare(marker)) { skipTo = i; matched = true; break } + } else { + skipTo = this.markers.length + } + while (iOld < skipTo) { + let next = this.markers[iOld++] + if (next.toDOM) { + next.destroy(domPos!) + let after = domPos!.nextSibling + domPos!.remove() + domPos = after + } + } + if (!marker) break + if (marker.toDOM) { + if (matched) domPos = domPos!.nextSibling + else this.dom.insertBefore(marker.toDOM(view), domPos) + } + if (matched) iOld++ + } + this.dom.className = cls + this.markers = markers + } + + destroy() { + this.setMarkers(null as any, []) // First argument not used unless creating markers + } +} + +function sameMarkers(a: readonly GutterMarker[], b: readonly GutterMarker[]): boolean { + if (a.length != b.length) return false + for (let i = 0; i < a.length; i++) if (!a[i].compare(b[i])) return false + return true +} + +interface LineNumberConfig { + /// How to display line numbers. Defaults to simply converting them + /// to string. + formatNumber?: (lineNo: number, state: EditorState) => string + /// Supply event handlers for DOM events on this gutter. + domEventHandlers?: Handlers +} + +/// Facet used to provide markers to the line number gutter. +export const lineNumberMarkers = Facet.define>() + +/// Facet used to create markers in the line number gutter next to widgets. +export const lineNumberWidgetMarker = Facet.define<(view: EditorView, widget: WidgetType, block: BlockInfo) => GutterMarker | null>() + +const lineNumberConfig = Facet.define>({ + combine(values) { + return combineConfig>(values, {formatNumber: String, domEventHandlers: {}}, { + domEventHandlers(a: Handlers, b: Handlers) { + let result: Handlers = Object.assign({}, a) + for (let event in b) { + let exists = result[event], add = b[event] + result[event] = exists ? (view, line, event) => exists(view, line, event) || add(view, line, event) : add + } + return result + } + }) + } +}) + +class NumberMarker extends GutterMarker { + constructor(readonly number: string) { super() } + + eq(other: NumberMarker) { return this.number == other.number } + + toDOM() { return document.createTextNode(this.number) } +} + +function formatNumber(view: EditorView, number: number) { + return view.state.facet(lineNumberConfig).formatNumber(number, view.state) +} + +const lineNumberGutter = activeGutters.compute([lineNumberConfig], state => ({ + class: "cm-lineNumbers", + renderEmptyElements: false, + markers(view: EditorView) { return view.state.facet(lineNumberMarkers) }, + lineMarker(view, line, others) { + if (others.some(m => m.toDOM)) return null + return new NumberMarker(formatNumber(view, view.state.doc.lineAt(line.from).number)) + }, + widgetMarker: (view, widget, block) => { + for (let m of view.state.facet(lineNumberWidgetMarker)) { + let result = m(view, widget, block) + if (result) return result + } + return null + }, + lineMarkerChange: update => update.startState.facet(lineNumberConfig) != update.state.facet(lineNumberConfig), + initialSpacer(view: EditorView) { + return new NumberMarker(formatNumber(view, maxLineNumber(view.state.doc.lines))) + }, + updateSpacer(spacer: GutterMarker, update: ViewUpdate) { + let max = formatNumber(update.view, maxLineNumber(update.view.state.doc.lines)) + return max == (spacer as NumberMarker).number ? spacer : new NumberMarker(max) + }, + domEventHandlers: state.facet(lineNumberConfig).domEventHandlers +})) + +/// Create a line number gutter extension. +export function lineNumbers(config: LineNumberConfig = {}): Extension { + return [ + lineNumberConfig.of(config), + gutters(), + lineNumberGutter + ] +} + +function maxLineNumber(lines: number) { + let last = 9 + while (last < lines) last = last * 10 + 9 + return last +} + +const activeLineGutterMarker = new class extends GutterMarker { + elementClass = "cm-activeLineGutter" +} + +const activeLineGutterHighlighter = gutterLineClass.compute(["selection"], state => { + let marks = [], last = -1 + for (let range of state.selection.ranges) { + let linePos = state.doc.lineAt(range.head).from + if (linePos > last) { + last = linePos + marks.push(activeLineGutterMarker.range(linePos)) + } + } + return RangeSet.of(marks) +}) + +/// Returns an extension that adds a `cm-activeLineGutter` class to +/// all gutter elements on the [active +/// line](#view.highlightActiveLine). +export function highlightActiveLineGutter() { + return activeLineGutterHighlighter +}