let langShortname = (lang: string) => switch lang { | "ocaml" => "ml" | "reasonml" | "reason" => "re" | "bash" => "sh" | "text" => "" | rest => rest } module DomUtil = { @scope("document") @val external createElement: string => Dom.element = "createElement" @scope("document") @val external createTextNode: string => Dom.element = "createTextNode" @send external appendChild: (Dom.element, Dom.element) => unit = "appendChild" @send external removeChild: (Dom.element, Dom.element) => unit = "removeChild" @set external setClassName: (Dom.element, string) => unit = "className" type classList @get external classList: Dom.element => classList = "classList" @send external toggle: (classList, string) => unit = "toggle" type animationFrameId @scope("window") @val external requestAnimationFrame: (unit => unit) => animationFrameId = "requestAnimationFrame" @scope("window") @val external cancelAnimationFrame: animationFrameId => unit = "cancelAnimationFrame" } module CopyButton = { let copyToClipboard: string => bool = %raw(` function(str) { try { const el = document.createElement('textarea'); el.value = str; el.setAttribute('readonly', ''); el.style.position = 'absolute'; el.style.left = '-9999px'; document.body.appendChild(el); const selected = document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false; el.select(); document.execCommand('copy'); document.body.removeChild(el); if (selected) { document.getSelection().removeAllRanges(); document.getSelection().addRange(selected); } return true; } catch(e) { return false; } } `) type state = | Init | Copied | Failed @react.component let make = (~code) => { let (state, setState) = React.useState(_ => Init) let buttonRef = React.useRef(Nullable.null) let onClick = evt => { ReactEvent.Mouse.preventDefault(evt) if copyToClipboard(code) { setState(_ => Copied) } else { setState(_ => Failed) } } React.useEffect(() => { switch state { | Copied => open DomUtil let buttonEl = Nullable.toOption(buttonRef.current)->Option.getExn // Note on this imperative DOM nonsense: // For Tailwind transitions to behave correctly, we need to first paint the DOM element in the tree, // and in the next tick, add the opacity-100 class, so the transition animation actually takes place. // If we don't do that, the banner will essentially pop up without any animation let bannerEl = createElement("div") bannerEl->setClassName( "opacity-0 absolute -top-6 right-0 -mt-5 -mr-4 px-4 py-2 w-40 rounded-lg captions text-white bg-gray-100 text-gray-80-tr transition-all duration-1000 ease-in-out ", ) let textNode = createTextNode("Copied to clipboard") bannerEl->appendChild(textNode) buttonEl->appendChild(bannerEl) let nextFrameId = requestAnimationFrame(() => { bannerEl->classList->toggle("opacity-0") bannerEl->classList->toggle("opacity-100") }) let timeoutId = setTimeout(() => { buttonEl->removeChild(bannerEl) setState(_ => Init) }, 3000) Some( () => { cancelAnimationFrame(nextFrameId) clearTimeout(timeoutId) }, ) | _ => None } }, [state]) //Copy-Button } } @react.component let make = (~highlightedLines=[], ~code: string, ~showLabel=true, ~lang="text") => { let children = HighlightJs.renderHLJS(~highlightedLines, ~code, ~lang, ()) let label = if showLabel { let label = langShortname(lang)
children