From b7074ffa432ee11d29665207f24ce18c79ed5588 Mon Sep 17 00:00:00 2001 From: Patrick Stapfer Date: Wed, 3 Feb 2021 20:10:05 +0100 Subject: [PATCH 1/2] Add initial scaffold for PlaygroundWidget --- src/bindings/Next.res | 2 +- src/bindings/Next.resi | 2 +- src/components/PlaygroundWidget.js | 80 +++++++++++++++++++++++++++++ src/components/PlaygroundWidget.res | 62 ++++++++++++++++++++++ src/layouts/LandingPageLayout.js | 5 +- src/layouts/LandingPageLayout.res | 1 + 6 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/components/PlaygroundWidget.js create mode 100644 src/components/PlaygroundWidget.res diff --git a/src/bindings/Next.res b/src/bindings/Next.res index e30d6f109..d539ea22e 100644 --- a/src/bindings/Next.res +++ b/src/bindings/Next.res @@ -138,5 +138,5 @@ module Dynamic = { @bs.module("next/dynamic") external dynamic: (unit => Js.Promise.t<'a>, options) => 'a = "default" - @bs.val external \"import": string => Js.Promise.t<'a> = "import" + @bs.val external import_: string => Js.Promise.t<'a> = "import" } diff --git a/src/bindings/Next.resi b/src/bindings/Next.resi index e5b4992a7..ec12be988 100644 --- a/src/bindings/Next.resi +++ b/src/bindings/Next.resi @@ -130,5 +130,5 @@ module Dynamic: { external dynamic: (unit => Js.Promise.t<'a>, options) => 'a = "default" @bs.val - external \"import": string => Js.Promise.t<'a> = "import" + external import_: string => Js.Promise.t<'a> = "import" } diff --git a/src/components/PlaygroundWidget.js b/src/components/PlaygroundWidget.js new file mode 100644 index 000000000..6b03b4d32 --- /dev/null +++ b/src/components/PlaygroundWidget.js @@ -0,0 +1,80 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Next from "../bindings/Next.js"; +import * as Curry from "bs-platform/lib/es6/curry.js"; +import * as React from "react"; +import * as Caml_option from "bs-platform/lib/es6/caml_option.js"; +import * as CodeExample from "./CodeExample.js"; +import Dynamic from "next/dynamic"; + +function PlaygroundWidget(Props) { + var initialCodeOpt = Props.initialCode; + var heightOpt = Props.height; + var initialCode = initialCodeOpt !== undefined ? initialCodeOpt : ""; + var height = heightOpt !== undefined ? heightOpt : "10rem"; + var match = React.useState(function () { + return /* Init */0; + }); + var setState = match[1]; + var editorCode = React.useRef(initialCode); + var typingTimer = React.useRef(undefined); + var timeoutCompile = React.useRef(function (param) { + + }); + var codeMirrorComponent = Dynamic((function (param) { + return import("src/components/CodeMirror.js").then(function (m) { + return m.make; + }); + }), Next.Dynamic.options(false, (function (param) { + return React.createElement(CodeExample.make, { + code: "Loading compiler...", + showLabel: false, + lang: "text" + }); + }), undefined)); + var codeMirrorEl = React.createElement(codeMirrorComponent, { + errors: [], + minHeight: height, + maxHeight: height, + className: "w-full py-4", + onChange: (function (value) { + editorCode.current = value; + var timer = typingTimer.current; + if (timer !== undefined) { + clearTimeout(Caml_option.valFromOption(timer)); + } + var timer$1 = setTimeout((function (param) { + Curry._1(timeoutCompile.current, undefined); + typingTimer.current = undefined; + + }), 100); + typingTimer.current = Caml_option.some(timer$1); + + }), + value: editorCode.current, + mode: "reason" + }); + return React.createElement("div", undefined, React.createElement("div", { + style: { + height: height + } + }, match[0] ? codeMirrorEl : React.createElement("div", undefined, React.createElement(CodeExample.make, { + code: initialCode, + showLabel: false, + lang: "res" + }), React.createElement("button", { + onClick: (function (evt) { + return Curry._1(setState, (function (param) { + return /* Edit */1; + })); + }) + }, "Edit")))); +} + +var make = PlaygroundWidget; + +export { + make , + +} +/* Next Not a pure module */ diff --git a/src/components/PlaygroundWidget.res b/src/components/PlaygroundWidget.res new file mode 100644 index 000000000..76ab5dc64 --- /dev/null +++ b/src/components/PlaygroundWidget.res @@ -0,0 +1,62 @@ +type state = + | Init + | Edit + +@react.component +let make = (~initialCode="", ~height="10rem") => { + let (state, setState) = React.useState(_ => Init) + let editorCode = React.useRef(initialCode) + let typingTimer = React.useRef(None) + + let timeoutCompile = React.useRef(() => ()) + + let codeMirrorComponent: React.component<'props> = Next.Dynamic.dynamic(() => { + Next.Dynamic.import_("src/components/CodeMirror.js")->Promise.then(m => { + m["make"] + }) + }, Next.Dynamic.options( + ~ssr=false, + ~loading=() => , + (), + )) + + let codeMirrorEl = React.createElement( + codeMirrorComponent, + CodeMirror.makeProps( + ~className="w-full py-4", + ~mode="reason", + ~errors=[], + ~minHeight=height, + ~maxHeight=height, + ~value=editorCode.current, + ~onChange=value => { + editorCode.current = value + + switch typingTimer.current { + | None => () + | Some(timer) => Js.Global.clearTimeout(timer) + } + + let timer = Js.Global.setTimeout(() => { + timeoutCompile.current() + typingTimer.current = None + }, 100) + typingTimer.current = Some(timer) + }, + (), + ), + ) + +
+
+ {switch state { + | Init => +
+ + +
+ | Edit => codeMirrorEl + }} +
+
+} diff --git a/src/layouts/LandingPageLayout.js b/src/layouts/LandingPageLayout.js index b61d985cd..bd6b30c66 100644 --- a/src/layouts/LandingPageLayout.js +++ b/src/layouts/LandingPageLayout.js @@ -8,6 +8,7 @@ import * as Footer from "../components/Footer.js"; import * as Markdown from "../components/Markdown.js"; import * as Navigation from "../components/Navigation.js"; import * as Caml_option from "bs-platform/lib/es6/caml_option.js"; +import * as PlaygroundWidget from "../components/PlaygroundWidget.js"; function LandingPageLayout$CallToActionButton(Props) { var children = Props.children; @@ -86,7 +87,9 @@ function LandingPageLayout(Props) { children: React.createElement("a", undefined, React.createElement(LandingPageLayout$SubtleButton, { children: "Read the Documentation" })) - })), children)) + })), React.createElement(PlaygroundWidget.make, { + initialCode: "let a = 1" + }), children)) }))), React.createElement(Footer.make, {}))))); } diff --git a/src/layouts/LandingPageLayout.res b/src/layouts/LandingPageLayout.res index 8ca719092..485025489 100644 --- a/src/layouts/LandingPageLayout.res +++ b/src/layouts/LandingPageLayout.res @@ -79,6 +79,7 @@ let make = (~components=Markdown.default, ~children) => { + children From 58002c777025d472d037687bd2fd5bebd8eb456a Mon Sep 17 00:00:00 2001 From: Patrick Stapfer Date: Thu, 11 Feb 2021 08:09:23 +0100 Subject: [PATCH 2/2] Play around with a few imperative instance call options --- src/components/CodeMirror2.js | 336 ++++++++++++++++++++ src/components/CodeMirror2.res | 474 ++++++++++++++++++++++++++++ src/components/PlaygroundWidget.js | 106 +++++-- src/components/PlaygroundWidget.res | 113 ++++++- 4 files changed, 988 insertions(+), 41 deletions(-) create mode 100644 src/components/CodeMirror2.js create mode 100644 src/components/CodeMirror2.res diff --git a/src/components/CodeMirror2.js b/src/components/CodeMirror2.js new file mode 100644 index 000000000..46bbd6bb0 --- /dev/null +++ b/src/components/CodeMirror2.js @@ -0,0 +1,336 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Curry from "bs-platform/lib/es6/curry.js"; +import * as React from "react"; +import * as Belt_Int from "bs-platform/lib/es6/belt_Int.js"; +import * as Belt_Array from "bs-platform/lib/es6/belt_Array.js"; +import * as Codemirror from "codemirror"; +import * as Belt_Option from "bs-platform/lib/es6/belt_Option.js"; +import * as Caml_option from "bs-platform/lib/es6/caml_option.js"; + +import "codemirror/lib/codemirror.css"; +import "styles/cm.css"; + +if (typeof window !== "undefined" && typeof window.navigator !== "undefined") { + require("codemirror/mode/javascript/javascript"); + require("codemirror/addon/scroll/simplescrollbars"); + require("plugins/cm-reason-mode"); +} +; + +var useWindowWidth = (() => { + const isClient = typeof window === 'object'; + + function getSize() { + return { + width: isClient ? window.innerWidth : 0, + height: isClient ? window.innerHeight : 0 + }; + } + + const [windowSize, setWindowSize] = React.useState(getSize); + + let throttled = false; + React.useEffect(() => { + if (!isClient) { + return false; + } + + function handleResize() { + if(!throttled) { + setWindowSize(getSize()); + + throttled = true; + setTimeout(() => { throttled = false }, 300); + } + } + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); // Empty array ensures that effect is only run on mount and unmount + + if(windowSize) { + return windowSize.width; + } + return null; + }); + +var errorGutterId = "errors"; + +var Options = {}; + +var TextMarker = {}; + +var $$Attr = {}; + +var MarkTextOption = { + $$Attr: $$Attr +}; + +var CM = { + errorGutterId: errorGutterId, + Options: Options, + TextMarker: TextMarker, + MarkTextOption: MarkTextOption +}; + +var $$Event = {}; + +var DomUtil = { + $$Event: $$Event +}; + +var $$Error = {}; + +function make(rowCol, kind, param) { + var marker = document.createElement("div"); + var colorClass = kind === "Error" ? "text-fire bg-fire-15" : "text-gold bg-gold-15"; + marker.id = "gutter-marker_" + rowCol[0] + "-" + rowCol[1]; + marker.className = "flex items-center justify-center text-14 text-center ml-1 h-6 font-bold hover:cursor-pointer " + colorClass; + marker.innerHTML = "!"; + return marker; +} + +var GutterMarker = { + make: make +}; + +function _clearMarks(state) { + Belt_Array.forEach(state.marked, (function (mark) { + mark.clear(); + + })); + state.marked = []; + +} + +function extractRowColFromId(id) { + var match = id.split("_"); + if (match.length !== 2) { + return ; + } + var rowColStr = match[1]; + var match$1 = rowColStr.split("-"); + if (match$1.length !== 2) { + return ; + } + var rowStr = match$1[0]; + var colStr = match$1[1]; + var row = Belt_Int.fromString(rowStr); + var col = Belt_Int.fromString(colStr); + if (row !== undefined && col !== undefined) { + return [ + row, + col + ]; + } + +} + +function updateErrors(state, onMarkerFocus, onMarkerFocusLeave, cm, errors) { + Belt_Array.forEach(state.marked, (function (mark) { + mark.clear(); + + })); + state.marked = []; + cm.clearGutter(errorGutterId); + var wrapper = cm.getWrapperElement(); + Belt_Array.forEachWithIndex(errors, (function (_idx, e) { + var marker = make([ + e.row, + e.column + ], e.kind, undefined); + wrapper.appendChild(marker); + var row = e.row - 1 | 0; + var endRow = e.endRow - 1 | 0; + cm.setGutterMarker(row, errorGutterId, marker); + var from_ch = e.column; + var from = { + line: row, + ch: from_ch + }; + var to__ch = e.endColumn; + var to_ = { + line: endRow, + ch: to__ch + }; + var match = e.kind; + var markTextColor = match === "Error" ? "border-fire" : "border-gold"; + var __x = cm.markText(from, to_, { + className: "border-b border-dotted hover:cursor-pointer " + markTextColor, + attributes: { + id: "text-marker_" + (String(e.row) + ("-" + (String(e.column) + ""))) + } + }); + state.marked.push(__x); + + })); + var isMarkerId = function (id) { + if (id.startsWith("gutter-marker")) { + return true; + } else { + return id.startsWith("text-marker"); + } + }; + wrapper.onmouseover = (function (evt) { + var target = evt.target; + var id = target.id; + if (!isMarkerId(id)) { + return ; + } + var rowCol = extractRowColFromId(id); + if (rowCol !== undefined) { + return Belt_Option.forEach(onMarkerFocus, (function (cb) { + return Curry._1(cb, rowCol); + })); + } + + }); + wrapper.onmouseout = (function (evt) { + var target = evt.target; + var id = target.id; + if (!isMarkerId(id)) { + return ; + } + var rowCol = extractRowColFromId(id); + if (rowCol !== undefined) { + return Belt_Option.forEach(onMarkerFocusLeave, (function (cb) { + return Curry._1(cb, rowCol); + })); + } + + }); + +} + +function CodeMirror2(Props) { + var errorsOpt = Props.errors; + var minHeight = Props.minHeight; + var maxHeight = Props.maxHeight; + var className = Props.className; + var style = Props.style; + var onChange = Props.onChange; + var onMarkerFocus = Props.onMarkerFocus; + var onMarkerFocusLeave = Props.onMarkerFocusLeave; + var value = Props.value; + var cmRef = Props.cmRef; + var mode = Props.mode; + var readOnlyOpt = Props.readOnly; + var lineNumbersOpt = Props.lineNumbers; + var scrollbarStyleOpt = Props.scrollbarStyle; + var lineWrappingOpt = Props.lineWrapping; + var errors = errorsOpt !== undefined ? errorsOpt : []; + var readOnly = readOnlyOpt !== undefined ? readOnlyOpt : false; + var lineNumbers = lineNumbersOpt !== undefined ? lineNumbersOpt : true; + var scrollbarStyle = scrollbarStyleOpt !== undefined ? scrollbarStyleOpt : "overlay"; + var lineWrapping = lineWrappingOpt !== undefined ? lineWrappingOpt : false; + var inputElement = React.useRef(null); + var cmStateRef = React.useRef({ + marked: [] + }); + var windowWidth = Curry._1(useWindowWidth, undefined); + React.useEffect((function () { + var input = inputElement.current; + if (input == null) { + return ; + } + var options = { + theme: "material", + gutters: [ + errorGutterId, + "CodeMirror-linenumbers" + ], + mode: mode, + lineNumbers: lineNumbers, + readOnly: readOnly, + lineWrapping: lineWrapping, + fixedGutter: false, + scrollbarStyle: scrollbarStyle + }; + var cm = Codemirror.fromTextArea(input, options); + Belt_Option.forEach(minHeight, (function (minHeight) { + cm.getScrollerElement().style.minHeight = minHeight; + + })); + Belt_Option.forEach(maxHeight, (function (maxHeight) { + cm.getScrollerElement().style.maxHeight = maxHeight; + + })); + Belt_Option.forEach(onChange, (function (onValueChange) { + cm.on("change", (function (instance) { + return Curry._1(onValueChange, instance.getValue()); + })); + + })); + cm.setValue(value); + cmRef.current = Caml_option.some(cm); + return (function (param) { + cm.toTextArea(); + cmRef.current = undefined; + + }); + }), []); + var cm = cmRef.current; + if (cm !== undefined) { + var cm$1 = Caml_option.valFromOption(cm); + if (cm$1.getValue() !== value) { + var state = cmStateRef.current; + cm$1.operation(function () { + return updateErrors(state, onMarkerFocus, onMarkerFocusLeave, cm$1, errors); + }); + cm$1.setValue(value); + } + + } + var errorsFingerprint = Belt_Array.map(errors, (function (e) { + return "" + e.row + "-" + e.column; + })).join(";"); + React.useEffect((function () { + var state = cmStateRef.current; + var cm = cmRef.current; + if (cm !== undefined) { + var cm$1 = Caml_option.valFromOption(cm); + cm$1.operation(function () { + return updateErrors(state, onMarkerFocus, onMarkerFocusLeave, cm$1, errors); + }); + } + + }), [errorsFingerprint]); + React.useEffect((function () { + var cm = cmRef.current; + if (cm !== undefined) { + Caml_option.valFromOption(cm).refresh(); + } + + }), [ + className, + windowWidth + ]); + var tmp = {}; + if (className !== undefined) { + tmp.className = Caml_option.valFromOption(className); + } + if (style !== undefined) { + tmp.style = Caml_option.valFromOption(style); + } + return React.createElement("div", tmp, React.createElement("textarea", { + ref: inputElement, + className: "hidden" + })); +} + +var make$1 = CodeMirror2; + +export { + useWindowWidth , + CM , + DomUtil , + $$Error , + GutterMarker , + _clearMarks , + extractRowColFromId , + updateErrors , + make$1 as make, + +} +/* Not a pure module */ diff --git a/src/components/CodeMirror2.res b/src/components/CodeMirror2.res new file mode 100644 index 000000000..e573b8c68 --- /dev/null +++ b/src/components/CodeMirror2.res @@ -0,0 +1,474 @@ +%%raw( + ` +import "codemirror/lib/codemirror.css"; +import "styles/cm.css"; + +if (typeof window !== "undefined" && typeof window.navigator !== "undefined") { + require("codemirror/mode/javascript/javascript"); + require("codemirror/addon/scroll/simplescrollbars"); + require("plugins/cm-reason-mode"); +} +` +) + +let useWindowWidth: unit => int = %raw( + j` () => { + const isClient = typeof window === 'object'; + + function getSize() { + return { + width: isClient ? window.innerWidth : 0, + height: isClient ? window.innerHeight : 0 + }; + } + + const [windowSize, setWindowSize] = React.useState(getSize); + + let throttled = false; + React.useEffect(() => { + if (!isClient) { + return false; + } + + function handleResize() { + if(!throttled) { + setWindowSize(getSize()); + + throttled = true; + setTimeout(() => { throttled = false }, 300); + } + } + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); // Empty array ensures that effect is only run on mount and unmount + + if(windowSize) { + return windowSize.width; + } + return null; + } + ` +) + +/* The module for interacting with the imperative CodeMirror API */ +module CM = { + type t + + let errorGutterId = "errors" + + module Options = { + @bs.deriving({abstract: light}) + type t = { + theme: string, + @bs.optional + gutters: array, + mode: string, + @bs.optional + lineNumbers: bool, + @bs.optional + readOnly: bool, + @bs.optional + lineWrapping: bool, + @bs.optional + fixedGutter: bool, + @bs.optional + scrollbarStyle: string, + } + } + + @bs.module("codemirror") + external fromTextArea: (Dom.element, Options.t) => t = "fromTextArea" + + @bs.send + external getScrollerElement: t => Dom.element = "getScrollerElement" + + @bs.send + external getWrapperElement: t => Dom.element = "getWrapperElement" + + @bs.send external refresh: t => unit = "refresh" + + @bs.send + external onChange: (t, @bs.as("change") _, @bs.uncurry (t => unit)) => unit = "on" + @bs.send external toTextArea: t => unit = "toTextArea" + + @bs.send external setValue: (t, string) => unit = "setValue" + + @bs.send external getValue: t => string = "getValue" + + @bs.send + external operation: (t, @bs.uncurry (unit => unit)) => unit = "operation" + + @bs.send + external setGutterMarker: (t, int, string, Dom.element) => unit = "setGutterMarker" + + @bs.send external clearGutter: (t, string) => unit = "clearGutter" + + type markPos = { + line: int, + ch: int, + } + + module TextMarker = { + type t + + @bs.send external clear: t => unit = "clear" + } + + module MarkTextOption = { + type t + + module Attr = { + type t + @bs.obj external make: (~id: string=?, unit) => t = "" + } + + @bs.obj + external make: (~className: string=?, ~attributes: Attr.t, unit) => t = "" + } + + @bs.send + external markText: (t, markPos, markPos, MarkTextOption.t) => TextMarker.t = "markText" +} + +module DomUtil = { + module Event = { + type t + + @bs.get external target: t => Dom.element = "target" + } + + @bs.val @bs.scope("document") + external createElement: string => Dom.element = "createElement" + + @bs.send + external appendChild: (Dom.element, Dom.element) => unit = "appendChild" + + @bs.set @bs.scope("style") + external setMinHeight: (Dom.element, string) => unit = "minHeight" + + @bs.set @bs.scope("style") + external setMaxHeight: (Dom.element, string) => unit = "maxHeight" + + @bs.set @bs.scope("style") + external _setDisplay: (Dom.element, string) => unit = "display" + + @bs.set @bs.scope("style") + external _setTop: (Dom.element, string) => unit = "top" + + @bs.set @bs.scope("style") + external _setLeft: (Dom.element, string) => unit = "left" + + @bs.set external setInnerHTML: (Dom.element, string) => unit = "innerHTML" + + @bs.set external setId: (Dom.element, string) => unit = "id" + @bs.set external setClassName: (Dom.element, string) => unit = "className" + + @bs.set + external setOnMouseOver: (Dom.element, Event.t => unit) => unit = "onmouseover" + + @bs.set + external _setOnMouseLeave: (Dom.element, Event.t => unit) => unit = "onmouseleave" + + @bs.set + external setOnMouseOut: (Dom.element, Event.t => unit) => unit = "onmouseout" + + @bs.get external getId: Dom.element => string = "id" + + type clientRect = { + x: int, + y: int, + width: int, + height: int, + } + + @bs.send + external _getBoundingClientRect: Dom.element => clientRect = "getBoundingClientRect" +} + +module Error = { + type kind = [#Error | #Warning] + + type t = { + row: int, + column: int, + endRow: int, + endColumn: int, + text: string, + kind: kind, + } +} + +module GutterMarker = { + // Note: this is not a React component + let make = (~rowCol: (int, int), ~kind: Error.kind, ()): Dom.element => { + // row, col + open DomUtil + + let marker = createElement("div") + let colorClass = switch kind { + | #Warning => "text-gold bg-gold-15" + | #Error => "text-fire bg-fire-15" + } + + let (row, col) = rowCol + marker->setId(j`gutter-marker_$row-$col`) + marker->setClassName( + "flex items-center justify-center text-14 text-center ml-1 h-6 font-bold hover:cursor-pointer " ++ + colorClass, + ) + marker->setInnerHTML("!") + + marker + } +} + +type state = {mutable marked: array} + +let _clearMarks = (state: state): unit => { + Belt.Array.forEach(state.marked, mark => mark->CM.TextMarker.clear) + state.marked = [] +} + +let extractRowColFromId = (id: string): option<(int, int)> => + switch Js.String2.split(id, "_") { + | [_, rowColStr] => + switch Js.String2.split(rowColStr, "-") { + | [rowStr, colStr] => + let row = Belt.Int.fromString(rowStr) + let col = Belt.Int.fromString(colStr) + switch (row, col) { + | (Some(row), Some(col)) => Some((row, col)) + | _ => None + } + | _ => None + } + | _ => None + } + +let updateErrors = (~state: state, ~onMarkerFocus=?, ~onMarkerFocusLeave=?, ~cm: CM.t, errors) => { + Belt.Array.forEach(state.marked, mark => mark->CM.TextMarker.clear) + state.marked = [] + cm->{ + open CM + clearGutter(errorGutterId) + } + + let wrapper = cm->CM.getWrapperElement + + Belt.Array.forEachWithIndex(errors, (_idx, e) => { + open DomUtil + open Error + + let marker = GutterMarker.make(~rowCol=(e.row, e.column), ~kind=e.kind, ()) + + wrapper->appendChild(marker) + + // CodeMirrors line numbers are (strangely enough) zero based + let row = e.row - 1 + let endRow = e.endRow - 1 + + cm->CM.setGutterMarker(row, CM.errorGutterId, marker) + + let from = {CM.line: row, ch: e.column} + let to_ = {CM.line: endRow, ch: e.endColumn} + + let markTextColor = switch e.kind { + | #Error => "border-fire" + | #Warning => "border-gold" + } + + cm + ->CM.markText( + from, + to_, + CM.MarkTextOption.make( + ~className="border-b border-dotted hover:cursor-pointer " ++ markTextColor, + ~attributes=CM.MarkTextOption.Attr.make( + ~id="text-marker_" ++ + (Belt.Int.toString(e.row) ++ + ("-" ++ (Belt.Int.toString(e.column) ++ ""))), + (), + ), + (), + ), + ) + ->Js.Array2.push(state.marked, _) + ->ignore + () + }) + + let isMarkerId = id => + Js.String2.startsWith(id, "gutter-marker") || Js.String2.startsWith(id, "text-marker") + + wrapper->{ + open DomUtil + setOnMouseOver(evt => { + let target = Event.target(evt) + + let id = getId(target) + if isMarkerId(id) { + switch extractRowColFromId(id) { + | Some(rowCol) => Belt.Option.forEach(onMarkerFocus, cb => cb(rowCol)) + | None => () + } + } + }) + } + + wrapper->{ + open DomUtil + setOnMouseOut(evt => { + let target = Event.target(evt) + + let id = getId(target) + if isMarkerId(id) { + switch extractRowColFromId(id) { + | Some(rowCol) => Belt.Option.forEach(onMarkerFocusLeave, cb => cb(rowCol)) + | None => () + } + } + }) + } +} + +@react.component +let make = // props relevant for the react wrapper +( + ~errors: array=[], + ~minHeight: option=?, + ~maxHeight: option=?, + ~className: option=?, + ~style: option=?, + ~onChange: option unit>=?, + ~onMarkerFocus: option<((int, int)) => unit>=?, // (row, column) + ~onMarkerFocusLeave: option<((int, int)) => unit>=?, // (row, column) + ~value: string, + // props for codemirror options + ~cmRef: React.ref>, + ~mode, + ~readOnly=false, + ~lineNumbers=true, + ~scrollbarStyle="overlay", + ~lineWrapping=false, +): React.element => { + let inputElement = React.useRef(Js.Nullable.null) + let cmStateRef = React.useRef({marked: []}) + + let windowWidth = useWindowWidth() + + React.useEffect0(() => + switch inputElement.current->Js.Nullable.toOption { + | Some(input) => + let options = CM.Options.t( + ~theme="material", + ~gutters=[CM.errorGutterId, "CodeMirror-linenumbers"], + ~mode, + ~lineWrapping, + ~fixedGutter=false, + ~readOnly, + ~lineNumbers, + ~scrollbarStyle, + (), + ) + let cm = CM.fromTextArea(input, options) + /* Js.log2("cm", cm); */ + + Belt.Option.forEach(minHeight, minHeight => + cm->CM.getScrollerElement->DomUtil.setMinHeight(minHeight) + ) + + Belt.Option.forEach(maxHeight, maxHeight => + cm->CM.getScrollerElement->DomUtil.setMaxHeight(maxHeight) + ) + + Belt.Option.forEach(onChange, onValueChange => + cm->CM.onChange(instance => onValueChange(instance->CM.getValue)) + ) + + // For some reason, injecting value with the options doesn't work + // so we need to set the initial value imperatively + cm->CM.setValue(value) + + cmRef.current = Some(cm) + + let cleanup = () => { + /* Js.log2("cleanup", options->CM.Options.mode); */ + + // This will destroy the CM instance + cm->CM.toTextArea + cmRef.current = None + } + + Some(cleanup) + | None => None + } + ) + + /* + Previously we did this in a useEffect([|value|) setup, but + this issues for syncing up the current editor value state + with the passed value prop. + + Example: Let's assume you press a format code button for a + piece of code that formats to the same value as the previously + passed value prop. Even though the source code looks different + in the editor (as observed via getValue) it doesn't recognize + that there is an actual change. + + By checking if the local state of the CM instance is different + to the input value, we can sync up both states accordingly + */ + switch cmRef.current { + | Some(cm) => + if CM.getValue(cm) === value { + () + } else { + let state = cmStateRef.current + cm->CM.operation(() => + updateErrors(~onMarkerFocus?, ~onMarkerFocusLeave?, ~state, ~cm, errors) + ) + cm->CM.setValue(value) + } + | None => () + } + + /* + This is required since the incoming error + array is not guaranteed to be the same instance, + so we need to make a single string that React's + useEffect is able to act on for equality checks + */ + let errorsFingerprint = Belt.Array.map(errors, e => { + let {Error.row: row, column} = e + j`$row-$column` + })->Js.Array2.joinWith(";") + + React.useEffect1(() => { + let state = cmStateRef.current + switch cmRef.current { + | Some(cm) => + cm->CM.operation(() => + updateErrors(~onMarkerFocus?, ~onMarkerFocusLeave?, ~state, ~cm, errors) + ) + | None => () + } + None + }, [errorsFingerprint]) + + /* + Needed in case the className visually hides / shows + a codemirror instance, or the window has been resized. + */ + React.useEffect2(() => { + switch cmRef.current { + | Some(cm) => cm->CM.refresh + | None => () + } + None + }, (className, windowWidth)) + +
+