Skip to content

Instantly share code, notes, and snippets.

@lukewestby
Last active December 9, 2021 08:12
Show Gist options
  • Save lukewestby/dec1cca157faea7aac7ffe5da3280324 to your computer and use it in GitHub Desktop.
Save lukewestby/dec1cca157faea7aac7ffe5da3280324 to your computer and use it in GitHub Desktop.
The custom element and Elm API that will drive Ellie's code editors

Some assumptions in case you want to copy and paste these files:

  • I installed CodeMirror with npm and loaded it with Babel and Webpack
  • I hardcoded a bunch of configuration to suit the needs of my software

A note about debouncing change events: Because Elm's update cycle runs in an animation frame scheduler, it's possible that you can edit the codemirror value more quickly than Elm can ask it for its current state. If we let the CodeMirror value settle for a couple hundred milliseconds then we can have higher confidence that the Elm value won't find a way to get out of sync.

Browser compatibility:

module Ellie.Ui.CodeEditor
exposing
( Attribute
, LinterMessage
, Position
, Severity(..)
, linterMessages
, mode
, onChange
, readOnly
, tabSize
, value
, view
, vim
)
import Html exposing (Html)
import Html.Attributes exposing (property)
import Html.Events as Events exposing (on)
import Json.Decode as Decode
import Json.Encode as Encode exposing (Value)
{-| Wrap the attributes that are allowed so that a user can't
insert arbitrary stuff from Html.Attributes or Html.Events
-}
type Attribute msg
= Attr (Html.Attribute msg)
unattr : Attribute msg -> Html.Attribute msg
unattr (Attr a) =
a
{-| The current value of the editor -}
value : String -> Attribute msg
value =
Encode.string >> property "value" >> Attr
{-| The language mode -}
mode : String -> Attribute msg
mode =
Encode.string >> property "mode" >> Attr
{-| Whether to run the editor in vim mode -}
vim : Bool -> Attribute msg
vim value =
Attr <| property "vimMode" <| Encode.bool value
{-| How many spaces to a tab -}
tabSize : Int -> Attribute msg
tabSize =
Encode.int >> property "tabSize" >> Attr
{-| Whether the editor's text can be changed. It would be cool if this was
set automatically by the presence or absence of a change listener
-}
readOnly : Attribute msg
readOnly =
Attr <| property "readOnly" <| Encode.bool True
{-| A list of hardcoded linter messages so that Elm error messages can be displayed
from the outside.
-}
linterMessages : List LinterMessage -> Attribute msg
linterMessages messages =
Attr <| property "linterMessages" <| Encode.list <| List.map linterMessageEncoder messages
{-| Listen for changes in the editor's value -}
onChange : (String -> msg) -> Attribute msg
onChange tagger =
Attr <| on "change" (Decode.map tagger Events.targetValue)
{-| Render a code editor -}
view : List (Attribute msg) -> Html msg
view attributes =
Html.node "code-editor" (List.map unattr attributes) []
{-| Level of a linter message -}
type Severity
= Error
| Warning
{-| Position of a linter message span's start and end -}
type alias Position =
{ line : Int
, column : Int
}
{-| Linter message -}
type alias LinterMessage =
{ severity : Severity
, message : String
, from : Position
, to : Position
}
linterMessageEncoder : LinterMessage -> Value
linterMessageEncoder linterMessage =
Encode.object
[ ( "severity", severityEncoder linterMessage.severity )
, ( "message", Encode.string linterMessage.message )
, ( "from", positionEncoder linterMessage.from )
, ( "to", positionEncoder linterMessage.to )
]
severityEncoder : Severity -> Value
severityEncoder severity =
case severity of
Error ->
Encode.string "error"
Warning ->
Encode.string "warning"
positionEncoder : Position -> Value
positionEncoder position =
Encode.object
[ ( "line", Encode.int position.line )
, ( "ch", Encode.int position.column )
, ( "sticky", Encode.null )
]
const load = () => {
return Promise.all([
import(/* webpackChunkName: "codemirror-base" */ 'codemirror/lib/codemirror'),
import(/* webpackChunkName: "codemirror-base", webpackMode: "eager" */ 'codemirror/lib/codemirror.css'),
import(/* webpackChunkName: "codemirror-base" */ 'codemirror/mode/elm/elm'),
import(/* webpackChunkName: "codemirror-base" */ 'codemirror/mode/htmlmixed/htmlmixed'),
import(/* webpackChunkName: "codemirror-base" */ 'codemirror/addon/lint/lint'),
import(/* webpackChunkName: "codemirror-base" */ 'codemirror/addon/selection/active-line'),
import(/* webpackChunkName: "codemirror-base", webpackMode: "eager" */ 'codemirror/addon/lint/lint.css'),
import(/* webpackChunkName: "codemirror-base", webpackMode: "eager" */ 'codemirror/theme/material.css'),
])
.then(([CodeMirror]) => CodeMirror)
}
const loadVimMode = () => {
return Promise.all([
import(/* webpackChunkName: "codemirror-vim" */ 'codemirror/keymap/vim'),
import(/* webpackChunkName: "codemirror-vim" */ 'codemirror/addon/dialog/dialog'),
import(/* webpackChunkName: "codemirror-vim", webpackMode: "eager" */ 'codemirror/addon/dialog/dialog.css')
])
.then(() => {})
}
const debounce = (func, wait) => {
let timeout
return function() {
var later = function() {
timeout = null
func.apply(null, arguments)
};
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
const initialize = () => {
return load().then(CodeMirror => {
CodeMirror.registerHelper('lint', 'elm', (text, options, instance) => {
return instance._errors || []
})
customElements.define('code-editor', class CodeEditor extends HTMLElement {
constructor() {
super()
this._linterFormatDiv = document.createElement('div')
this._ready = false
this._value = ''
this._tabSize = 4
this._readOnly = false
this._mode = 'htmlmixed'
this._instance = null
this._errors = []
this._vimMode = false
this._vimModeLoading = false
}
get vimMode() {
return this._vimMode
}
set vimMode(value) {
if (value === null) value = false
this._vimMode = value
if (!this._vimModeLoading && this._vimMode) {
this._vimModeLoading = true
loadVimMode().then(() => {
if (!this._instance) return
this._instance.setOption('keyMap', this._vimMode ? 'vim' : 'default')
})
} else if (this._instance) {
this._instance.setOption('keyMap', this._vimMode ? 'vim' : 'default')
}
}
get value() {
return this._value
}
set value(value) {
if (value !== null && value !== this._value) {
const prevScrollPosition = instance.getScrollInfo()
this._instance.setValue(value)
this._instance.scrollTo(prevScrollPosition.left, prevScrollPosition.top)
this._value = value
}
}
get tabSize() {
return this._tabSize
}
set tabSize(value) {
if (value === null) value = 4
this._tabSize = value
if (!this._instance) return
this._instance.setOption('indentWidth', this._tabSize)
this._instance.setOption('tabSize', this._tabSize)
this._instance.setOption('indentUnit', this._tabSize)
}
get readOnly() {
return this._readOnly
}
set readOnly(value) {
if (value === null) value = false
this._readOnly = value
this._instance.setOption('readOnly', value)
}
get mode() {
return this._mode
}
set mode(value) {
if (value === null) value = 'htmlmixed'
this._mode = value
if (!this._instance) return
this._instance.setOption('mode', this._mode)
}
get linterMessages() {
return this._errors
}
set linterMessages(value) {
if (value === null) value = []
this._errors = value
if (!this._instance) return
this._instance._errors = this.formatLinterMessages(value)
this._instance.performLint()
}
connectedCallback() {
if (this._instance) return
this._instance = CodeMirror(this, {
lineNumbers: true,
styleActiveLine: { nonEmpty: true },
smartIndent: true,
indentWithTabs: false,
keyMap: this._vimMode ? 'vim' : 'default',
lint: { lintOnChange: false },
theme: 'material',
indentWidth: this._tabSize,
tabSize: this._tabSize,
indentUnit: this._tabSize,
readOnly: this._readOnly,
mode: this._mode,
value: this._value,
extraKeys: {
Tab(cm) {
let x = ""
for (let i = cm.getOption('indentUnit'); i > 0; i--) x += " "
cm.replaceSelection(x)
}
}
})
const runDispatch = debounce(() => {
this._value = this._instance.getValue()
const event = new Event('change')
this.dispatchEvent(event)
}, 200)
this._instance.on('change', runDispatch)
requestAnimationFrame(() => {
this._instance.refresh()
})
if (this._vimMode && !this._vimModeLoading) {
this._vimModeLoading = true
loadVimMode()
}
}
formatLinterMessages(messages) {
return messages.map(message => {
this._linterFormatDiv.innerHTML = message.message
return {
from: message.from,
to: message.to,
message: linterFormatDiv.innerText,
severity: message.severity
}
})
}
})
})
}
export default { initialize }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment