From a958b25ae0a33cab84225603815ac3044b524b0b Mon Sep 17 00:00:00 2001 From: Pete Murphy <26548438+ptrfrncsmrph@users.noreply.github.com> Date: Sat, 10 Sep 2022 09:48:11 +0000 Subject: [PATCH] Embed editor state in URL; remove session storage (#299) * Embed editor state in URL; remove session storage * Add to CHANGELOG * Add "Share URL" button * Update README --- CHANGELOG.md | 1 + README.md | 12 ++--- client/package.json | 3 +- client/src/Try/Container.js | 11 +++++ client/src/Try/Container.purs | 88 +++++++++++++++++++++++---------- client/src/Try/QueryString.js | 5 ++ client/src/Try/QueryString.purs | 12 +++++ client/src/Try/Session.js | 16 ------ client/src/Try/Session.purs | 55 --------------------- 9 files changed, 100 insertions(+), 103 deletions(-) delete mode 100644 client/src/Try/Session.js delete mode 100644 client/src/Try/Session.purs diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c15f2f..2dbba176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Notable changes to this project are documented in this file. The format is based Breaking changes: New features: +- Remove `localStorage` for session storage, persist editor state in URL query param (#299 by @ptrfrncsmrph) Bugfixes: diff --git a/README.md b/README.md index 716fc332..7e8671d0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - PureScript syntax highlighting - Run and print output or show resulting JavaScript - Multiple view modes: code, output or both -- Persistent session +- Shareable code and editor state via URL - Load PureScript code from GitHub Gists or repository files ### Control Features via the Query String @@ -28,6 +28,11 @@ Most of these features can be controlled not only from the toolbar, but also usi - Example: `gist=37c3c97f47a43f20c548` - Notes: the file should be named `Main.purs` with the module name `Main`. +- **Load From URL**: Load compressed PureScript code using the `code` parameter + - Managed by Try PureScript and updated on editor state change to create shareable URLs + - Format: `code=` + - Example: `code=LYewJgrgNgpgBAWQIYEsB2cDuALGAnGIA` will set the editor state to the single line `module Main where` + - **View Mode**: Control the view mode using the `view` parameter - Options are: `code`, `output`, `both` (default) - Example: `view=output` will only display the output @@ -40,11 +45,6 @@ Most of these features can be controlled not only from the toolbar, but also usi - Options are: `true`, `false` (default) - Example: `js=true` will print JavaScript code instead of the program's output -- **Session**: Load code from a session which is stored with [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) using the `session` parameter - - Usually managed by Try PureScript - - Example: `session=9162f098-070f-4053-60ea-eba47021450d` (Note: will probably not work for you) - - When used with the `gist` or `github` query parameters the code will be loaded from the source file and not the session - ### Which Libraries Are Available? Try PureScript aims to provide a complete, recent package set from . The available libraries are those listed in [`staging/spago.dhall`](./staging/spago.dhall), at the versions in the package set mentioned in [`staging/packages.dhall`](./staging/packages.dhall). diff --git a/client/package.json b/client/package.json index a26db675..1e937d81 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "ace-builds": "^1.5.0", - "jquery": "^1.12.4" + "jquery": "^1.12.4", + "lz-string": "^1.4.4" } } diff --git a/client/src/Try/Container.js b/client/src/Try/Container.js index 72550304..bc558045 100644 --- a/client/src/Try/Container.js +++ b/client/src/Try/Container.js @@ -57,3 +57,14 @@ export function setupIFrame(data, loadCb, failCb) { return $iframe; } + +export function copyToClipboard(string, copyCb, failCb) { + try { + navigator.clipboard.writeText(string).then( + () => copyCb(), + () => failCb() + ); + } catch (_error) { + failCb(); + } +} diff --git a/client/src/Try/Container.purs b/client/src/Try/Container.purs index cb34b606..9a2c27be 100644 --- a/client/src/Try/Container.purs +++ b/client/src/Try/Container.purs @@ -6,7 +6,7 @@ import Ace (Annotation) import Control.Monad.Except (runExceptT) import Data.Array as Array import Data.Either (Either(..), either) -import Data.Foldable (for_, oneOf, fold) +import Data.Foldable (for_, oneOf, fold, traverse_) import Data.FoldableWithIndex (foldMapWithIndex) import Data.Maybe (Maybe(..), fromMaybe, isNothing) import Data.String as String @@ -14,7 +14,7 @@ import Data.String (Pattern(..)) import Data.String.Regex as Regex import Data.String.Regex.Flags as RegexFlags import Effect (Effect) -import Effect.Aff (Aff, makeAff) +import Effect.Aff (Aff, Milliseconds(..), delay, makeAff) import Effect.Aff as Aff import Effect.Class.Console (error) import Effect.Uncurried (EffectFn3, runEffectFn3) @@ -30,14 +30,14 @@ import Try.Editor (MarkerType(..), toStringMarkerType) import Try.Editor as Editor import Try.Gist (getGistById, tryLoadFileFromGist) import Try.GitHub (getRawGitHubFile) -import Try.QueryString (getQueryStringMaybe) -import Try.Session (createSessionIdIfNecessary, storeSession, tryRetrieveSession) +import Try.QueryString (compressToEncodedURIComponent, decompressFromEncodedURIComponent, getQueryStringMaybe, setQueryString) import Try.SharedConfig as SharedConfig import Type.Proxy (Proxy(..)) import Web.HTML (window) -import Web.HTML.Window (alert) +import Web.HTML.Location (href) +import Web.HTML.Window (alert, location) -type Slots = ( editor :: Editor.Slot Unit ) +type Slots = ( editor :: Editor.Slot Unit, shareButton :: forall q o. H.Slot q o Unit ) data SourceFile = GitHub String | Gist String @@ -76,7 +76,7 @@ parseViewModeParam = case _ of data Action = Initialize - | Cache String + | EncodeInURL String | UpdateSettings (Settings -> Settings) | Compile (Maybe String) | HandleEditor Editor.Output @@ -84,10 +84,11 @@ data Action _editor :: Proxy "editor" _editor = Proxy -type LoadCb = Effect Unit +type SucceedCb = Effect Unit type FailCb = Effect Unit -foreign import setupIFrame :: EffectFn3 { code :: String } LoadCb FailCb Unit +foreign import setupIFrame :: EffectFn3 { code :: String } SucceedCb FailCb Unit foreign import teardownIFrame :: Effect Unit +foreign import copyToClipboard :: EffectFn3 String SucceedCb FailCb Unit component :: forall q i o. H.Component q i o Aff component = H.mkComponent @@ -109,8 +110,7 @@ component = H.mkComponent handleAction :: Action -> H.HalogenM State Action Slots o Aff Unit handleAction = case _ of Initialize -> do - sessionId <- H.liftEffect $ createSessionIdIfNecessary - { code, sourceFile } <- H.liftAff $ withSession sessionId + { code, sourceFile } <- H.liftAff withSession -- Load parameters mbViewModeParam <- H.liftEffect $ getQueryStringMaybe "view" @@ -140,13 +140,8 @@ component = H.mkComponent else handleAction $ Compile Nothing - Cache text -> H.liftEffect do - sessionId <- getQueryStringMaybe "session" - case sessionId of - Just sessionId_ -> do - storeSession sessionId_ { code: text } - Nothing -> - error "No session ID" + EncodeInURL text -> H.liftEffect do + setQueryString "code" $ compressToEncodedURIComponent text Compile mbCode -> do H.modify_ _ { compiled = Nothing } @@ -209,7 +204,7 @@ component = H.mkComponent H.modify_ _ { compiled = Just res } HandleEditor (Editor.TextChanged text) -> do - _ <- H.fork $ handleAction $ Cache text + _ <- H.fork $ handleAction $ EncodeInURL text { autoCompile } <- H.gets _.settings when autoCompile $ handleAction $ Compile $ Just text @@ -340,6 +335,7 @@ component = H.mkComponent ] [ HH.text "Show JS" ] ] + , HH.slot_ (Proxy :: _ "shareButton") unit shareButton unit , HH.li [ HP.class_ $ HH.ClassName "menu-item" ] [ HH.a @@ -442,6 +438,48 @@ renderCompilerErrors errors = do , renderPlaintext message ] +type ShareButtonState = + { forkId :: Maybe H.ForkId + , showCopySucceeded :: Maybe Boolean + } + +shareButton :: forall q i o. H.Component q i o Aff +shareButton = H.mkComponent + { initialState: \_ -> { forkId: Nothing, showCopySucceeded: Nothing } + , render + , eval: H.mkEval $ H.defaultEval + { handleAction = handleAction + } + } + where + handleAction :: Unit -> H.HalogenM ShareButtonState Unit () o Aff Unit + handleAction _ = do + H.gets _.forkId >>= traverse_ H.kill + url <- H.liftEffect $ window >>= location >>= href + copySucceeded <- H.liftAff $ makeAff \f -> do + runEffectFn3 copyToClipboard url (f (Right true)) (f (Right false)) + mempty + forkId <- H.fork do + H.liftAff $ delay (1_500.0 # Milliseconds) + H.modify_ _ { showCopySucceeded = Nothing } + H.put { showCopySucceeded: Just copySucceeded, forkId: Just forkId } + render :: ShareButtonState -> H.ComponentHTML Unit () Aff + render { showCopySucceeded } = do + let + message = case showCopySucceeded of + Just true -> "️✅ Copied to clipboard" + Just false -> "️❌ Failed to copy" + Nothing -> "Share URL" + HH.li + [ HP.class_ $ HH.ClassName "menu-item no-mobile" ] + [ HH.label + [ HP.id "share_label" + , HP.title "Share URL" + , HE.onClick \_ -> unit + ] + [ HH.text message ] + ] + menuRadio :: forall w . { name :: String @@ -505,13 +543,13 @@ toAnnotation markerType { position, message } = , text: message } -withSession :: String -> Aff { sourceFile :: Maybe SourceFile, code :: String } -withSession sessionId = do - state <- H.liftEffect $ tryRetrieveSession sessionId - githubId <- H.liftEffect $ getQueryStringMaybe "github" +withSession :: Aff { sourceFile :: Maybe SourceFile, code :: String } +withSession = do + state <- H.liftEffect $ getQueryStringMaybe "code" + githubId <- H.liftEffect $ getQueryStringMaybe "github" gistId <- H.liftEffect $ getQueryStringMaybe "gist" - code <- case state of - Just { code } -> pure code + code <- case state >>= decompressFromEncodedURIComponent of + Just code -> pure code Nothing -> do let action = oneOf diff --git a/client/src/Try/QueryString.js b/client/src/Try/QueryString.js index 5f3f03c5..2fdcf9d7 100644 --- a/client/src/Try/QueryString.js +++ b/client/src/Try/QueryString.js @@ -1,3 +1,8 @@ +export { + compressToEncodedURIComponent, + decompressFromEncodedURIComponent as decompressFromEncodedURIComponent_, +} from 'lz-string'; + export function getQueryString() { return window.location.search; } diff --git a/client/src/Try/QueryString.purs b/client/src/Try/QueryString.purs index cb7bf4cb..d6933f66 100644 --- a/client/src/Try/QueryString.purs +++ b/client/src/Try/QueryString.purs @@ -3,6 +3,8 @@ module Try.QueryString , getQueryStringMaybe , setQueryString , setQueryStrings + , compressToEncodedURIComponent + , decompressFromEncodedURIComponent ) where import Prelude @@ -10,6 +12,7 @@ import Prelude import Data.Array as Array import Data.Maybe (Maybe(..)) import Data.Newtype (wrap) +import Data.Nullable (Nullable, toMaybe) import Data.String as String import Data.Tuple (Tuple(..)) import Effect (Effect) @@ -58,3 +61,12 @@ setQueryStrings :: Object.Object String -> Effect Unit setQueryStrings ss = do params <- getQueryParams runEffectFn1 setQueryParameters (Object.union ss params) + +-- | Compress a string to a URI-encoded string using LZ-based compression algorithm +foreign import compressToEncodedURIComponent :: String -> String + +foreign import decompressFromEncodedURIComponent_ :: String -> Nullable String + +-- | Decompress a string from a URI-encoded string using LZ-based compression algorithm +decompressFromEncodedURIComponent :: String -> Maybe String +decompressFromEncodedURIComponent = toMaybe <$> decompressFromEncodedURIComponent_ diff --git a/client/src/Try/Session.js b/client/src/Try/Session.js deleted file mode 100644 index 7edfd148..00000000 --- a/client/src/Try/Session.js +++ /dev/null @@ -1,16 +0,0 @@ -export function storeSession_(sessionId, state) { - if (window.localStorage) { - localStorage.setItem(sessionId, state.code); - localStorage.setItem(sessionId + 'backend', state.backend); - } -} - -export function tryRetrieveSession_(sessionId) { - if (window.localStorage) { - var code = localStorage.getItem(sessionId); - var backend = localStorage.getItem(sessionId + 'backend'); - if (code && backend) { - return { code: code, backend: backend }; - } - } -} diff --git a/client/src/Try/Session.purs b/client/src/Try/Session.purs deleted file mode 100644 index 5f54ed00..00000000 --- a/client/src/Try/Session.purs +++ /dev/null @@ -1,55 +0,0 @@ -module Try.Session - ( storeSession - , tryRetrieveSession - , createSessionIdIfNecessary - ) where - -import Prelude - -import Data.Functor.App (App(..)) -import Data.Int (hexadecimal, toStringAs) -import Data.Maybe (Maybe(..)) -import Data.Newtype (unwrap) -import Data.Nullable (Nullable, toMaybe) -import Data.String as String -import Effect (Effect) -import Effect.Random (randomInt) -import Effect.Uncurried (EffectFn1, EffectFn2, runEffectFn1, runEffectFn2) -import Try.QueryString (getQueryStringMaybe, setQueryString) - -randomGuid :: Effect String -randomGuid = - unwrap (App s4 <> App s4 <> pure "-" <> - App s4 <> pure "-" <> - App s4 <> pure "-" <> - App s4 <> pure "-" <> - App s4 <> App s4 <> App s4) - where - s4 = padLeft <<< toStringAs hexadecimal <$> randomInt 0 (256 * 256) - padLeft s = String.drop (String.length s - 1) ("000" <> s) - -foreign import storeSession_ :: EffectFn2 String { code :: String } Unit - --- | Store the current session state in local storage -storeSession - :: String - -> { code :: String } - -> Effect Unit -storeSession sessionId values = runEffectFn2 storeSession_ sessionId values - -foreign import tryRetrieveSession_ :: EffectFn1 String (Nullable { code :: String }) - --- | Retrieve the session state from local storage -tryRetrieveSession :: String -> Effect (Maybe { code :: String }) -tryRetrieveSession sessionId = toMaybe <$> runEffectFn1 tryRetrieveSession_ sessionId - --- | Look up the session by ID, or create a new session ID. -createSessionIdIfNecessary :: Effect String -createSessionIdIfNecessary = do - sessionId <- getQueryStringMaybe "session" - case sessionId of - Just sessionId_ -> pure sessionId_ - Nothing -> do - id <- randomGuid - setQueryString "session" id - pure id