@@ -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
8485module 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+
125134module 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+
148164module 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
156175module 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+
183248module 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
246337module 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