Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/lib/components/Hero.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
border-block-end: var(--space-px) solid var(--bg-300);
margin-block-end: var(--space-8);
background-image:
radial-gradient(circle at top right, rgb(from var(--accent-400) r g b / 0.07) 0%, transparent 90%),
radial-gradient(circle at top left, rgb(from var(--purple-300) r g b / 0.1) 0%, transparent 70%);
radial-gradient(circle at top right, rgb(from var(--accent-400) r g b / 0.07) 0%, transparent 84%),
radial-gradient(circle at top left, rgb(from var(--purple-300) r g b / 0.1) 0%, transparent 65%);

@media (min-height: 33rem) {
padding-block-start: var(--space-16);
Expand Down
66 changes: 48 additions & 18 deletions src/lib/components/css-form/Form.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { browser } from '$app/environment'
import type { FormSuccessEvent } from './types'
import { onMount } from 'svelte'
import type { FormSuccessEvent, CssFormHashState } from './types'
import FormGroup from '$components/FormGroup.svelte'
import Label from '$components/Label.svelte'
import Button from '$components/Button.svelte'
Expand All @@ -11,8 +12,10 @@
import Textarea from './Textarea.svelte'
import UrlInput from './UrlInput.svelte'
import FileInput from './FileInput.svelte'
import ShareButton from './ShareButton.svelte'
import { get_css, type CssFetchNetworkError, type CssFetchApiError, type CssFetchRemoteError } from '$lib/get-css'
import { get_css_state } from '$lib/css-state.svelte'
import { decodeHashState, encodeHashState } from '$lib/url-hash-state.svelte'
import { IsOnline } from '$lib/is-online.svelte'
import type { Snippet } from 'svelte'

Expand All @@ -26,7 +29,7 @@

let { on_success = noop, on_error = noop, title: title_snippet }: Props = $props()

let status: 'idle' | 'fetching' | 'error' = $state('idle')
let status: 'idle' | 'fetching' | 'done' | 'error' = $state('idle')
let error: Error | undefined = $state()
let url = $state('')
let css_state = get_css_state()
Expand All @@ -35,6 +38,22 @@
)
let is_online = new IsOnline()

onMount(async () => {
if (!window.location.hash) return
status = 'fetching'
const state = await decodeHashState<CssFormHashState>(window.location.hash)
if (!state?.origins?.length) {
status = 'idle'
return
}

css_state.set_origins(state.origins)
css_state.prettify(state.prettify)
prettify = state.prettify
status = 'done'
on_success({ origins: state.origins, prettify: state.prettify, submit_type: state.submit_type })
})

$effect(() => {
css_state.prettify(prettify)
})
Expand All @@ -45,6 +64,8 @@
let input_val = form_data.get('raw-css')
let val = String(input_val)

history.replaceState(null, '', window.location.pathname + window.location.search)

Check failure on line 67 in src/lib/components/css-form/Form.svelte

View workflow job for this annotation

GitHub Actions / Lint JS

unicorn(no-null)

Do not use `null` literals

// Remove ?url= and prettify= query parameters from the URL
let cleaned_url = page.url
cleaned_url.searchParams.delete('url')
Expand All @@ -55,20 +76,23 @@
status = 'idle'

prettify = form_data.get('prettify') === '1'
on_success({
origins: [{ css: val, type: 'raw' }],
submit_type: 'raw',
prettify
})
css_state.set_origins([{ css: val, type: 'raw' }])
status = 'done'
const raw_origins = [{ css: val, type: 'raw' as const }]
on_success({ origins: raw_origins, submit_type: 'raw', prettify })
css_state.set_origins(raw_origins)
css_state.url = undefined
encodeHashState<CssFormHashState>({ submit_type: 'raw', origins: raw_origins, prettify }).then(
(encoded) => { window.location.hash = encoded }
)
}

async function on_submit_url(event: SubmitEvent) {
event.preventDefault()
if (status === 'fetching') return
status = 'fetching'

history.replaceState(null, '', window.location.pathname + window.location.search)

Check failure on line 94 in src/lib/components/css-form/Form.svelte

View workflow job for this annotation

GitHub Actions / Lint JS

unicorn(no-null)

Do not use `null` literals

let form_data = new FormData(event.target as HTMLFormElement)
let url = String(form_data.get('url'))
if (!url) return
Expand All @@ -85,14 +109,14 @@
status = 'idle'

prettify = form_data.get('prettify') === '1'
status = 'done'
css_state.prettify(prettify)
css_state.set_origins(origins)
css_state.url = url
on_success({
origins,
submit_type: 'url',
prettify
})
on_success({ origins, submit_type: 'url', prettify })
encodeHashState<CssFormHashState>({ submit_type: 'url', origins, prettify }).then(
(encoded) => { window.location.hash = encoded }
)
} catch (err: unknown) {
status = 'error'
error = err as CssFetchNetworkError | CssFetchApiError | CssFetchRemoteError
Expand All @@ -115,6 +139,8 @@
// fail silently
}

history.replaceState(null, '', window.location.pathname + window.location.search)

Check failure on line 142 in src/lib/components/css-form/Form.svelte

View workflow job for this annotation

GitHub Actions / Lint JS

unicorn(no-null)

Do not use `null` literals

// Remove ?url= and prettify= query parameters from the URL
let cleaned_url = page.url
cleaned_url.searchParams.delete('url')
Expand All @@ -125,13 +151,13 @@
status = 'idle'

prettify = form_data.get('prettify') === '1'
on_success({
origins,
submit_type: 'file',
prettify
})
status = 'done'
on_success({ origins, submit_type: 'file', prettify })
css_state.set_origins(origins)
css_state.url = undefined
encodeHashState<CssFormHashState>({ submit_type: 'file', origins, prettify }).then(
(encoded) => { window.location.hash = encoded }
)
}

$effect(() => {
Expand Down Expand Up @@ -240,6 +266,10 @@
{/snippet}
</InputModeSwitcher>

{#if status === 'done'}
<ShareButton />
{/if}

<style>
form {
display: grid;
Expand Down
13 changes: 13 additions & 0 deletions src/lib/components/css-form/ShareButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
import Button from '$components/Button.svelte'

let copied = $state(false)

async function share() {
await navigator.clipboard.writeText(window.location.href)
copied = true
setTimeout(() => { copied = false }, 2000)
}
</script>

<Button onclick={share} size="sm">{copied ? 'Copied!' : 'Copy share link'}</Button>
6 changes: 6 additions & 0 deletions src/lib/components/css-form/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ export type FormSuccessEvent = {
prettify: boolean
submit_type: 'raw' | 'url' | 'file'
}

export type CssFormHashState = {
submit_type: 'raw' | 'url' | 'file'
origins: CSSOrigin[]
prettify: boolean
}
4 changes: 2 additions & 2 deletions src/lib/is-online.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ export class IsOnline {
private is_online: boolean

constructor() {
// We assume we're starting out online, otherwise the page with this Class couldn't have loaded at all.
// This assumption prevents a quick online/offline/online jump on page loading, causing layout shifts.
// We assume we're starting out online, otherwise the page with this class couldn't have loaded at all.
// This assumption prevents a quick online/offline/online jump on page loading, wich causes layout shifts.
this.is_online = $state(true)

$effect(() => {
Expand Down
99 changes: 69 additions & 30 deletions src/lib/url-hash-state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,68 @@
* - Common pattern in playgrounds (TypeScript, postcss-preset-env, etc.)
*/

const encoder = new TextEncoder()
const decoder = new TextDecoder()

async function compress(input: string): Promise<string> {
const stream = new CompressionStream('deflate-raw')
const writer = stream.writable.getWriter()
writer.write(encoder.encode(input))
writer.close()
const buffer = await new Response(stream.readable).arrayBuffer()
let binary = ''
for (const byte of new Uint8Array(buffer)) {
binary += String.fromCharCode(byte)
}
// URL-safe base64: avoids + and / which need percent-encoding in URLs
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')

Check failure on line 27 in src/lib/url-hash-state.svelte.ts

View workflow job for this annotation

GitHub Actions / Lint JS

unicorn(prefer-string-replace-all)

Prefer `String#replaceAll()` over `String#replace()` when using a regex with the global flag.

Check failure on line 27 in src/lib/url-hash-state.svelte.ts

View workflow job for this annotation

GitHub Actions / Lint JS

unicorn(prefer-string-replace-all)

Prefer `String#replaceAll()` over `String#replace()` when using a regex with the global flag.

Check failure on line 27 in src/lib/url-hash-state.svelte.ts

View workflow job for this annotation

GitHub Actions / Lint JS

unicorn(prefer-string-replace-all)

Prefer `String#replaceAll()` over `String#replace()` when using a regex with the global flag.
}

async function decompress(input: string): Promise<string> {
// Restore standard base64 from URL-safe variant
const base64 = input.replace(/-/g, '+').replace(/_/g, '/')

Check failure on line 32 in src/lib/url-hash-state.svelte.ts

View workflow job for this annotation

GitHub Actions / Lint JS

unicorn(prefer-string-replace-all)

Prefer `String#replaceAll()` over `String#replace()` when using a regex with the global flag.

Check failure on line 32 in src/lib/url-hash-state.svelte.ts

View workflow job for this annotation

GitHub Actions / Lint JS

unicorn(prefer-string-replace-all)

Prefer `String#replaceAll()` over `String#replace()` when using a regex with the global flag.
const binary = atob(base64)
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
const stream = new DecompressionStream('deflate-raw')
const writer = stream.writable.getWriter()
writer.write(bytes)
writer.close()
const buffer = await new Response(stream.readable).arrayBuffer()
return decoder.decode(buffer)
}

/**
* Decode a base64-encoded JSON value from the URL hash.
* Encode a value to a compressed, URL-safe base64 string for use in the URL hash.
*/
export async function encodeHashState<T>(value: T): Promise<string> {

Check warning on line 46 in src/lib/url-hash-state.svelte.ts

View workflow job for this annotation

GitHub Actions / Lint JS

eslint(require-await)

Async function has no `await` expression.
return compress(JSON.stringify(value))
}

/**
* Decode a compressed base64 value from the URL hash.
* Returns undefined if hash is empty or invalid.
*/
export function decodeHashState<T>(hash: string): T | undefined {
export async function decodeHashState<T>(hash: string): Promise<T | undefined> {
const value = hash.startsWith('#') ? hash.slice(1) : hash
if (!value) return
if (!value) return undefined

try {
// TODO: use Uint8Array.fromBase64() when browsers support it
return JSON.parse(decodeURIComponent(atob(value))) as T
return JSON.parse(await decompress(value)) as T
} catch {
// fail silently
}
}

/**
* Encode a value to base64 JSON for use in the URL hash.
*/
export function encodeHashState<T>(value: T): string {
// TODO: use Uint8Array.toBase64() when browsers support it
return btoa(encodeURIComponent(JSON.stringify(value)))
function merge_with_defaults<T>(defaultValue: T, decoded: T): T {
if (typeof decoded === 'object' && decoded !== null && typeof defaultValue === 'object' && defaultValue !== null) {
return { ...defaultValue, ...decoded } as T
}
return decoded
}

/**
* Reactive state synced with the URL hash.
* Reads initial value from hash on mount, writes back with debounce on change.
* Reads initial value from hash asynchronously on mount, writes back with debounce on change.
*
* @example
* const css = new HashState(defaultCss)
Expand All @@ -49,36 +84,40 @@
export class HashState<T> {
#value = $state<T>() as T
#debounceMs: number
#initialized = $state(false)

constructor(defaultValue: T, debounceMs = 300) {
this.#value = defaultValue
this.#debounceMs = debounceMs

// Read hash synchronously to avoid flash of default content
if (browser) {
const decoded = decodeHashState<T>(window.location.hash)
if (
typeof decoded === 'object' &&
decoded !== null &&
typeof defaultValue === 'object' &&
defaultValue !== null
) {
this.#value = { ...defaultValue, ...decoded } as T
} else {
this.#value = decoded ?? defaultValue
$effect(() => {
if (!browser) {
this.#initialized = true
return
}
} else {
this.#value = defaultValue
}
decodeHashState<T>(window.location.hash).then((decoded) => {
if (decoded !== undefined) {
this.#value = merge_with_defaults(defaultValue, decoded)
}
this.#initialized = true
})
})

$effect(() => {
if (!browser) return
if (!browser || !this.#initialized) return

// JSON.stringify forces deep tracking of all nested properties
const serialized = JSON.stringify(this.#value)
const timeout = setTimeout(() => {
void goto(`#${btoa(encodeURIComponent(serialized))}`, { replaceState: true, noScroll: true, keepFocus: true })
let cancelled = false
const timeout = setTimeout(async () => {
if (cancelled) return
const encoded = await encodeHashState(JSON.parse(serialized))
if (!cancelled) {
void goto(`#${encoded}`, { replaceState: true, noScroll: true, keepFocus: true })
}
}, this.#debounceMs)
return () => {
cancelled = true
clearTimeout(timeout)
}
})
Expand Down
Loading