321 lines
12 KiB
TypeScript
Executable file
321 lines
12 KiB
TypeScript
Executable file
// AVA — Script ⇢ Hash share dialogs (v7.8).
|
|
//
|
|
// Two dialogs used by the Script tab toolbar:
|
|
// • ScriptToHashDialog — encodes the current editor script into ONE
|
|
// shareable Unicode string (see lib/ava/share-codec.ts for the format),
|
|
// shows it with a Copy button, live stats and the symbol legend.
|
|
// Compiling is NOT required — sharing a broken script to get it
|
|
// debugged is a feature.
|
|
// • HashToScriptDialog — pastes a share string back; on success it shows
|
|
// what came out (name, size, compile status) and loads the script into
|
|
// the editor exactly as it was, ready to Apply / Save As….
|
|
|
|
'use client'
|
|
|
|
import { useMemo, useState } from 'react'
|
|
import { ArrowRightLeft, Check, ClipboardPaste, CircleHelp, FileCode2, Share2, X } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { decodeScriptHash, encodeScriptHash, SHARE_LEGEND } from '@/lib/ava/share-codec'
|
|
import { validateScript } from '@/lib/ava/script'
|
|
import { useToast } from '@/hooks/use-toast'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
/** Copy text to the clipboard with a legacy fallback. */
|
|
async function copyText(text: string): Promise<boolean> {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
return true
|
|
} catch {
|
|
try {
|
|
const ta = document.createElement('textarea')
|
|
ta.value = text
|
|
ta.style.position = 'fixed'
|
|
ta.style.opacity = '0'
|
|
document.body.appendChild(ta)
|
|
ta.select()
|
|
const ok = document.execCommand('copy')
|
|
ta.remove()
|
|
return ok
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Script ⇢ Hash ─────────────────────────────────────────────────────────
|
|
|
|
export function ScriptToHashDialog({
|
|
open,
|
|
onOpenChange,
|
|
scriptText,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
scriptText: string
|
|
}) {
|
|
const { toast } = useToast()
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
const encoded = useMemo(() => encodeScriptHash(scriptText), [scriptText])
|
|
const savedPct = Math.round((1 - encoded.hashLength / Math.max(1, encoded.scriptLength)) * 100)
|
|
const empty = scriptText.trim() === ''
|
|
|
|
async function copy() {
|
|
const ok = await copyText(encoded.hash)
|
|
if (ok) {
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
} else {
|
|
toast({
|
|
title: 'Copy failed',
|
|
description: 'Select the string in the box and copy it manually (⌘/Ctrl+C).',
|
|
variant: 'destructive',
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Share2 className="size-4 text-emerald-400" aria-hidden />
|
|
Script ⇢ Hash
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Your script as ONE shareable string -- post it anywhere (chat, social, forums). Anyone
|
|
pastes it back with Hash⇢Script and gets your script exactly as it is now.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3">
|
|
<Textarea
|
|
readOnly
|
|
value={encoded.hash}
|
|
aria-label="The share string for this script"
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
rows={5}
|
|
spellCheck={false}
|
|
className="max-h-[240px] min-h-[110px] resize-none border-zinc-700 bg-zinc-950 font-mono text-xs leading-relaxed text-emerald-200/90"
|
|
/>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="font-mono text-[11px] text-zinc-500" role="status">
|
|
{encoded.hashLength} characters
|
|
{savedPct > 0 ? (
|
|
<span className="text-emerald-400"> · {savedPct}% shorter than the script</span>
|
|
) : null}
|
|
{empty ? <span className="text-amber-400"> · the editor is empty</span> : null}
|
|
</p>
|
|
<Button
|
|
onClick={() => void copy()}
|
|
className="h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
|
|
>
|
|
{copied ? <Check className="size-3.5" /> : <ClipboardPaste className="size-3.5" />}
|
|
{copied ? 'Copied!' : 'Copy string'}
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="rounded-md border border-zinc-800 bg-zinc-900/40 px-2.5 py-2 text-[11px] leading-relaxed text-zinc-400">
|
|
Short scripts spell themselves in built-in references (
|
|
<span className="font-mono text-zinc-300">
|
|
{SHARE_LEGEND.map((l) => `${l.symbol} = ${l.meaning}`).join(' · ')}
|
|
</span>
|
|
) -- longer ones additionally pack ~15 bits of script into every
|
|
character, which is why they read as dense CJK. Either way the string
|
|
works even if the script doesn't compile -- perfect for asking for
|
|
help or sharing work in progress.
|
|
</p>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" className="h-9 border-zinc-700 text-xs" onClick={() => onOpenChange(false)}>
|
|
Done
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ── Hash ⇢ Script ─────────────────────────────────────────────────────────
|
|
|
|
interface Decoded {
|
|
script: string
|
|
name: string | null
|
|
lines: number
|
|
compiles: boolean
|
|
compileNote: string
|
|
}
|
|
|
|
export function HashToScriptDialog({
|
|
open,
|
|
onOpenChange,
|
|
onImport,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
/** Load the decoded script into the editor (script-tab wires this).
|
|
* v7.11: also hands back the script's display name (its name: header,
|
|
* else the share-string label) so Save can auto-name the new preset. */
|
|
onImport: (script: string, name: string | null) => void
|
|
}) {
|
|
const { toast } = useToast()
|
|
const [input, setInput] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [decoded, setDecoded] = useState<Decoded | null>(null)
|
|
|
|
// Reset whenever the dialog is (re)opened. The toolbar button sets the
|
|
// parent's open state directly, so Radix's onOpenChange never fires for
|
|
// it — resetting during render on the closed→open transition is the
|
|
// React-blessed pattern (re-renders immediately, no effect needed).
|
|
const [wasOpen, setWasOpen] = useState(false)
|
|
if (open && !wasOpen) {
|
|
setWasOpen(true)
|
|
setInput('')
|
|
setError(null)
|
|
setDecoded(null)
|
|
} else if (!open && wasOpen) {
|
|
setWasOpen(false)
|
|
}
|
|
|
|
function tryDecode() {
|
|
const text = input.trim()
|
|
if (!text) {
|
|
setError('Paste a share string first.')
|
|
setDecoded(null)
|
|
return
|
|
}
|
|
const result = decodeScriptHash(text)
|
|
if (!result.ok) {
|
|
setError(result.error)
|
|
setDecoded(null)
|
|
return
|
|
}
|
|
setError(null)
|
|
const v = validateScript(result.script)
|
|
const meta = v.meta
|
|
const lines = result.script.split('\n').length
|
|
const compiles = v.ok
|
|
setDecoded({
|
|
script: result.script,
|
|
name: meta && meta.name !== 'Untitled' ? meta.name : result.name,
|
|
lines,
|
|
compiles,
|
|
compileNote: v.ok
|
|
? `Compiles OK -- mode: ${meta?.mode ?? '?'}`
|
|
: v.error
|
|
? `Doesn't compile (yet) -- [${v.error.section}] line ${v.error.line}: ${v.error.message}`
|
|
: "Doesn't compile (yet)",
|
|
})
|
|
}
|
|
|
|
function load() {
|
|
if (!decoded) return
|
|
onImport(decoded.script, decoded.name ?? null)
|
|
onOpenChange(false)
|
|
toast({
|
|
title: 'Script loaded from share string',
|
|
description: `"${decoded.name ?? 'Untitled'}" -- ${decoded.lines} lines in the editor.${
|
|
decoded.compiles ? ' Hit Apply to run it, or Save to keep it as a preset.' : ' Fix the errors, then Apply.'
|
|
}`,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<ClipboardPaste className="size-4 text-emerald-400" aria-hidden />
|
|
Hash ⇢ Script
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Paste a share string someone posted -- the script it carries reappears in the editor,
|
|
exactly as they wrote it, ready to test or save.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3">
|
|
<Textarea
|
|
value={input}
|
|
onChange={(e) => {
|
|
setInput(e.target.value)
|
|
setError(null)
|
|
}}
|
|
placeholder={'My Script:[▼…]'}
|
|
aria-label="Paste a share string here"
|
|
rows={5}
|
|
spellCheck={false}
|
|
autoFocus
|
|
className="max-h-[240px] min-h-[110px] resize-none border-zinc-700 bg-zinc-950 font-mono text-xs leading-relaxed text-zinc-200"
|
|
/>
|
|
|
|
{error && (
|
|
<p className="flex items-start gap-1.5 break-words font-mono text-[11px] leading-relaxed text-rose-400" role="alert">
|
|
<X className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{decoded && (
|
|
<div className="space-y-2 rounded-lg border border-emerald-400/25 bg-emerald-400/5 p-3">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
|
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-emerald-200">
|
|
<FileCode2 className="size-3.5 shrink-0" aria-hidden />
|
|
<span className="truncate">{decoded.name ?? 'Untitled'}</span>
|
|
</span>
|
|
<span className="font-mono text-[11px] text-zinc-400">
|
|
{decoded.lines} lines · {decoded.script.length} characters
|
|
</span>
|
|
</div>
|
|
<p
|
|
className={cn(
|
|
'flex items-start gap-1.5 break-words font-mono text-[11px] leading-relaxed',
|
|
decoded.compiles ? 'text-emerald-400' : 'text-amber-400',
|
|
)}
|
|
>
|
|
{decoded.compiles ? (
|
|
<Check className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
|
) : (
|
|
<CircleHelp className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
|
)}
|
|
{decoded.compileNote}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button variant="outline" className="h-9 border-zinc-700 text-xs" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
{!decoded ? (
|
|
<Button
|
|
onClick={tryDecode}
|
|
className="h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
|
|
>
|
|
<ArrowRightLeft className="size-3.5" /> Decode
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={load}
|
|
className="h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
|
|
>
|
|
<FileCode2 className="size-3.5" /> Load into Editor
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|