Skip to content

Commit 0427b47

Browse files
Look ma, no raw
1 parent 3e2c475 commit 0427b47

1 file changed

Lines changed: 181 additions & 85 deletions

File tree

packages/dev-playground/src/Bindings.res

Lines changed: 181 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ module Window = {
7979
@val external setTimeout: (unit => unit, int) => int = "setTimeout"
8080
@val external clearTimeout: int => unit = "clearTimeout"
8181
@val external requestAnimationFrame: (unit => unit) => unit = "window.requestAnimationFrame"
82+
@val external isSecureContext: bool = "window.isSecureContext"
8283
}
8384

8485
module Url = {
@@ -122,6 +123,14 @@ module EventTarget = {
122123
}
123124
}
124125

126+
module CssStyle = {
127+
type t
128+
129+
@set external setPosition: (t, string) => unit = "position"
130+
@set external setTop: (t, string) => unit = "top"
131+
@set external setLeft: (t, string) => unit = "left"
132+
}
133+
125134
module Element = {
126135
@send external setAttribute: (Dom.element, string, string) => unit = "setAttribute"
127136
@send
@@ -130,6 +139,8 @@ module Element = {
130139
external removeEventListener: (Dom.element, string, Dom.event => unit) => unit =
131140
"removeEventListener"
132141
@send external appendChild: (Dom.element, Dom.element) => unit = "appendChild"
142+
@send external removeChild: (Dom.element, Dom.element) => unit = "removeChild"
143+
@get external style: Dom.element => CssStyle.t = "style"
133144
@get @return(nullable)
134145
external getScrollHandler: Dom.element => option<Dom.event => unit> =
135146
"__devPlaygroundScrollHandler"
@@ -145,12 +156,20 @@ module ScriptElement = {
145156
@set external setOnError: (Dom.element, unknown => unit) => unit = "onerror"
146157
}
147158

159+
module TextAreaElement = {
160+
@set external setValue: (Dom.element, string) => unit = "value"
161+
@send external select: Dom.element => unit = "select"
162+
}
163+
148164
module Document = {
149165
@val external current: {..} = "document"
150166
@get external head: {..} => Dom.element = "head"
167+
@get external body: {..} => Dom.element = "body"
151168
@send external createScriptElement: ({..}, @as("script") _) => Dom.element = "createElement"
169+
@send external createTextAreaElement: ({..}, @as("textarea") _) => Dom.element = "createElement"
152170
@send @return(nullable)
153171
external getElementById: ({..}, string) => option<Dom.element> = "getElementById"
172+
@send external execCommand: ({..}, string) => bool = "execCommand"
154173
}
155174

156175
module UrlSearchParams = {
@@ -180,97 +199,174 @@ module Performance = {
180199
@val @scope("performance") external now: unit => float = "now"
181200
}
182201

202+
module Base64 = {
203+
@val external encode: string => string = "btoa"
204+
@val external decode: string => string = "atob"
205+
}
206+
207+
module WebTextEncoder = {
208+
type t
209+
210+
@new external make: unit => t = "TextEncoder"
211+
@send external encode: (t, string) => Uint8Array.t = "encode"
212+
}
213+
214+
module WebTextDecoder = {
215+
type t
216+
217+
@new external make: unit => t = "TextDecoder"
218+
@send external decode: (t, Uint8Array.t) => string = "decode"
219+
}
220+
221+
module WebDecompressionStream = {
222+
type t
223+
224+
@val external supported: option<unknown> = "globalThis.DecompressionStream"
225+
@new external make: string => t = "DecompressionStream"
226+
}
227+
228+
module ReadableStream = {
229+
type t
230+
231+
@send external pipeThrough: (t, WebDecompressionStream.t) => t = "pipeThrough"
232+
}
233+
234+
module WebBlob = {
235+
type t
236+
237+
@new external make: array<Uint8Array.t> => t = "Blob"
238+
@send external stream: t => ReadableStream.t = "stream"
239+
}
240+
241+
module WebResponse = {
242+
type t
243+
244+
@new external make: ReadableStream.t => t = "Response"
245+
@send external arrayBuffer: t => promise<ArrayBuffer.t> = "arrayBuffer"
246+
}
247+
183248
module SharedCode = {
184-
let encode = async source => {
185-
ignore(source)
186-
let encoded: string = %raw(`
187-
(() => {
188-
const bytes = new TextEncoder().encode(source);
189-
let binary = "";
190-
const chunkSize = 0x8000;
191-
for (let index = 0; index < bytes.length; index += chunkSize) {
192-
const chunk = bytes.subarray(index, index + chunkSize);
193-
binary += String.fromCharCode(...chunk);
194-
}
195-
return "b:" + btoa(binary)
196-
.replace(/\+/g, "-")
197-
.replace(/\//g, "_")
198-
.replace(/=+$/g, "");
199-
})()
200-
`)
201-
encoded
249+
let bytesToBinary = bytes => {
250+
let chunkSize = 0x8000
251+
let length = bytes->TypedArray.length
252+
let chunks: array<string> = []
253+
254+
let rec collect = start =>
255+
if start < length {
256+
let end_ = Math.Int.min(start + chunkSize, length)
257+
let chunk = bytes->TypedArray.subarray(~start, ~end=end_)
258+
let chars = Array.fromInitializer(~length=end_ - start, index =>
259+
chunk->TypedArray.get(index)->Option.getOr(0)
260+
)
261+
chunks->Array.push(chars->String.fromCharCodeMany)
262+
collect(end_)
263+
}
264+
265+
collect(0)
266+
chunks->Array.join("")
202267
}
203268

204-
let decode = encoded => {
205-
ignore(encoded)
206-
let decoded: promise<string> = %raw(`
207-
(async () => {
208-
const base64UrlToBytes = value => {
209-
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
210-
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
211-
const binary = atob(padded);
212-
const bytes = new Uint8Array(binary.length);
213-
for (let index = 0; index < binary.length; index += 1) {
214-
bytes[index] = binary.charCodeAt(index);
215-
}
216-
return bytes;
217-
};
218-
219-
if (encoded.startsWith("z:")) {
220-
if (typeof DecompressionStream === "undefined") {
221-
throw new Error(
222-
"Compressed shared links require browser DecompressionStream support",
223-
);
224-
}
225-
226-
const compressedBytes = base64UrlToBytes(encoded.slice(2));
227-
const stream = new Blob([compressedBytes])
228-
.stream()
229-
.pipeThrough(new DecompressionStream("gzip"));
230-
return new TextDecoder().decode(
231-
new Uint8Array(await new Response(stream).arrayBuffer()),
232-
);
233-
}
234-
235-
if (encoded.startsWith("b:")) {
236-
return new TextDecoder().decode(base64UrlToBytes(encoded.slice(2)));
237-
}
238-
239-
return encoded;
240-
})()
241-
`)
242-
decoded
269+
let base64UrlToBytes = value => {
270+
let base64 = value->String.replaceAll("-", "+")->String.replaceAll("_", "/")
271+
let remainder = mod(base64->String.length, 4)
272+
let padded = switch remainder {
273+
| 0 => base64
274+
| remainder => base64->String.padEnd(base64->String.length + 4 - remainder, "=")
275+
}
276+
let binary = padded->Base64.decode
277+
let length = binary->String.length
278+
let bytes = Uint8Array.fromLength(length)
279+
280+
for index in 0 to length - 1 {
281+
bytes->TypedArray.set(index, binary->String.charCodeAtUnsafe(index))
282+
}
283+
284+
bytes
243285
}
286+
287+
let encode = async source => {
288+
let bytes = WebTextEncoder.make()->WebTextEncoder.encode(source)
289+
"b:" ++
290+
bytes
291+
->bytesToBinary
292+
->Base64.encode
293+
->String.replaceAllRegExp(/\+/g, "-")
294+
->String.replaceAllRegExp(/\//g, "_")
295+
->String.replaceAllRegExp(/=+$/g, "")
296+
}
297+
298+
let decode = async encoded =>
299+
if encoded->String.startsWith("z:") {
300+
switch WebDecompressionStream.supported {
301+
| None =>
302+
JsError.throwWithMessage(
303+
"Compressed shared links require browser DecompressionStream support",
304+
)
305+
| Some(_) =>
306+
let compressedBytes = encoded->String.slice(~start=2)->base64UrlToBytes
307+
let stream =
308+
WebBlob.make([compressedBytes])
309+
->WebBlob.stream
310+
->ReadableStream.pipeThrough(WebDecompressionStream.make("gzip"))
311+
let buffer = await WebResponse.make(stream)->WebResponse.arrayBuffer
312+
WebTextDecoder.make()->WebTextDecoder.decode(Uint8Array.fromBuffer(buffer))
313+
}
314+
} else if encoded->String.startsWith("b:") {
315+
WebTextDecoder.make()->WebTextDecoder.decode(
316+
encoded->String.slice(~start=2)->base64UrlToBytes,
317+
)
318+
} else {
319+
encoded
320+
}
321+
}
322+
323+
module NavigatorClipboard = {
324+
type t
325+
326+
@val external current: option<t> = "navigator.clipboard"
327+
@get @return(nullable) external writeTextMethod: t => option<unknown> = "writeText"
328+
@send external writeText: (t, string) => promise<unit> = "writeText"
329+
330+
let canWriteText = clipboard =>
331+
switch clipboard->writeTextMethod {
332+
| Some(writeText) => writeText->Type.typeof === #function
333+
| None => false
334+
}
244335
}
245336

246337
module Clipboard = {
247-
let writeText = value => {
248-
ignore(value)
249-
let promise: promise<unit> = %raw(`
250-
(async () => {
251-
if (navigator.clipboard?.writeText != null && window.isSecureContext) {
252-
await navigator.clipboard.writeText(value);
253-
return;
254-
}
255-
256-
const textarea = document.createElement("textarea");
257-
textarea.value = value;
258-
textarea.setAttribute("readonly", "");
259-
textarea.style.position = "fixed";
260-
textarea.style.top = "-9999px";
261-
textarea.style.left = "-9999px";
262-
document.body.appendChild(textarea);
263-
textarea.select();
264-
265-
try {
266-
if (!document.execCommand("copy")) {
267-
throw new Error("Copy command failed");
268-
}
269-
} finally {
270-
document.body.removeChild(textarea);
271-
}
272-
})()
273-
`)
274-
promise
338+
let writeWithFallback = value => {
339+
let document = Document.current
340+
let textarea = document->Document.createTextAreaElement
341+
textarea->TextAreaElement.setValue(value)
342+
textarea->Element.setAttribute("readonly", "")
343+
344+
let style = textarea->Element.style
345+
style->CssStyle.setPosition("fixed")
346+
style->CssStyle.setTop("-9999px")
347+
style->CssStyle.setLeft("-9999px")
348+
349+
let body = document->Document.body
350+
body->Element.appendChild(textarea)
351+
textarea->TextAreaElement.select
352+
353+
let copied = switch document->Document.execCommand("copy") {
354+
| copied => Ok(copied)
355+
| exception _ => Error()
356+
}
357+
358+
body->Element.removeChild(textarea)
359+
360+
switch copied {
361+
| Ok(true) => ()
362+
| Ok(false) | Error() => JsError.throwWithMessage("Copy command failed")
363+
}
275364
}
365+
366+
let writeText = async value =>
367+
switch NavigatorClipboard.current {
368+
| Some(clipboard) if Window.isSecureContext && clipboard->NavigatorClipboard.canWriteText =>
369+
await clipboard->NavigatorClipboard.writeText(value)
370+
| _ => writeWithFallback(value)
371+
}
276372
}

0 commit comments

Comments
 (0)