Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { app, isProjectEmpty } from './lib/state/store'
import {
applyLoadedProject,
downloadStateFile,
readProjectFromFile,
readPresetProject,
} from './lib/state/persistence'
import { generateBatch } from './lib/generate/generation-api'
import { stlBuffersByModelId } from './lib/state/sessionAssets'
import KeycapModelsEditor from './lib/ui/KeycapModelsEditor.svelte'
import TemplateEditor from './lib/ui/TemplateEditor.svelte'
import KeyEditor from './lib/ui/KeyEditor.svelte'
import ProcessingModal from './lib/ui/ProcessingModal.svelte'
import ModalContainer from './lib/ui/ModalContainer.svelte'
import { showMessage, showConfirm } from './lib/state/modalStore'
import { Download, Upload, BookOpen, ChevronDown, Trash2, Github, Star, Heart, Coffee, Plus } from 'lucide-svelte'
import { registerCustomFonts } from './lib/generate/fonts'
import ModeToggle from './lib/ui/ModeToggle.svelte'
import { initTheme } from './lib/state/theme'
import { Button } from '@/lib/components/ui/button'
import { ButtonGroup } from '@/lib/components/ui/button-group'
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@/lib/components/ui/empty'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/lib/components/ui/dropdown-menu'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/lib/components/ui/tabs'
import { getPublicPath } from './lib/utils/paths'
let tab: 'models' | 'templates' | 'keys' = 'models'
let presetsMenuOpen = false
let loadInput: HTMLInputElement
let hasInteracted = false
const presets = [
{ value: 'numpad', label: 'Numpad' },
{ value: '40-percent-ortho', label: '40% Ortholinear' },
{ value: '60-percent', label: '60% ANSI' },
]
function startCleanProject() {
hasInteracted = true
}
function onLoadPreset(presetValue: string, fromEmptyState = false) {
presetsMenuOpen = false
const load = async () => {
const result = await readPresetProject(presetValue)
if ('error' in result) showMessage(result.error)
else {
applyLoadedProject(result.project)
hasInteracted = true
}
}
if (fromEmptyState || isProjectEmpty($app)) {
load()
} else {
showConfirm('Loading a preset will replace your current project. Continue?', load)
}
}
function onClear() {
showConfirm('This will delete all models, templates, and keys. Continue?', () => {
stlBuffersByModelId.set({})
app.set({
version: 1,
keycapModels: [],
templates: [],
keys: [],
customFonts: [],
ui: {
selectedKeycapModelId: null,
selectedTemplateId: null,
selectedKeyId: null,
},
})
})
}
$: registerCustomFonts($app.customFonts)
let isGenerating = false
let progressCurrent = 0
let progressTotal = 0
let currentGeneratingKeyId: string | null = null
let generateAbortController: AbortController | null = null
$: requiredModelIds = Array.from(
new Set(
$app.keys
.map(k => $app.templates.find(t => t.id === k.templateId)?.keycapModelId)
.filter((x): x is string => typeof x === 'string')
)
)
$: missingUploadModels = requiredModelIds
.map(id => $app.keycapModels.find(m => m.id === id))
.filter((m): m is NonNullable<typeof m> => !!m)
.filter(m => m.source.kind === 'upload' && !$stlBuffersByModelId[m.id])
async function onGenerate() {
if (missingUploadModels.length > 0 || isGenerating) return
isGenerating = true
progressCurrent = 0
progressTotal = $app.keys.length
currentGeneratingKeyId = null
generateAbortController = new AbortController()
try {
await generateBatch({
state: $app,
stlBuffersByModelId: $stlBuffersByModelId,
onProgress: p => {
progressCurrent = p.current
progressTotal = p.total
currentGeneratingKeyId = p.keyId
},
signal: generateAbortController.signal,
})
// Keep modal open briefly to show completion, then close
await new Promise(r => setTimeout(r, 1500))
} catch (e) {
console.error(e)
if (!(e instanceof Error && e.message === 'Generation cancelled')) {
showMessage(e instanceof Error ? e.message : 'Generation failed.')
}
} finally {
isGenerating = false
generateAbortController = null
progressCurrent = 0
progressTotal = 0
currentGeneratingKeyId = null
}
}
$: currentKey = currentGeneratingKeyId ? $app.keys.find(k => k.id === currentGeneratingKeyId) : null
$: currentTemplate = currentKey ? $app.templates.find(t => t.id === currentKey.templateId) : null
$: currentModel = currentTemplate ? $app.keycapModels.find(m => m.id === currentTemplate.keycapModelId) : null
function onCancelGenerate() {
if (generateAbortController) {
generateAbortController.abort()
generateAbortController = null
isGenerating = false
}
}
$: hasUnsavedChanges = !isProjectEmpty($app)
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (hasUnsavedChanges) {
event.preventDefault()
return ''
}
}
onMount(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
return initTheme({ defaultTheme: 'dark', storageKey: 'vite-ui-theme' })
})
onDestroy(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
</script>
<div class="flex min-h-dvh flex-col">
<header class="border-b bg-background/80 backdrop-blur">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-2 px-3 py-3 sm:flex-row sm:items-center sm:gap-3 sm:px-4">
<div class="flex-1">
<div class="flex items-center gap-2 h-12">
<img src={getPublicPath('icon.png')} alt="Keycap Generator Icon" class="h-12 mr-2 w-auto object-contain" />
<div class="flex-1">
<div class="text-lg font-semibold leading-tight">Keycap Generator</div>
<div class="text-xs text-muted-foreground">Custom keycap generator for multi-color 3D printing</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5 sm:gap-2 sm:ml-auto">
<input
bind:this={loadInput}
class="hidden"
type="file"
accept="application/json"
on:change={async e => {
const result = await readProjectFromFile(e)
if (!result) return
if ('error' in result) showMessage(result.error)
else {
applyLoadedProject(result.project)
hasInteracted = true
}
}}
/>
{#if hasInteracted}
<ButtonGroup>
<Button variant="outline" size="sm" onclick={() => downloadStateFile($app)} title="Save project">
<Download class="size-4" />
<span class="hidden sm:inline">Save</span>
</Button>
<Button variant="outline" size="sm" onclick={() => loadInput?.click()} title="Load project">
<Upload class="size-4" />
<span class="hidden sm:inline">Load</span>
</Button>
</ButtonGroup>
<DropdownMenu bind:open={presetsMenuOpen}>
<DropdownMenuTrigger>
{#snippet child({ props })}
<Button variant="outline" size="sm" title="Load preset" {...props}>
<BookOpen class="size-4" />
<span class="sm:hidden">Presets</span>
<span class="hidden sm:inline">Load preset</span>
<ChevronDown class="size-3" />
</Button>
{/snippet}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-[160px]">
{#each presets as preset}
<DropdownMenuItem onclick={() => onLoadPreset(preset.value)}>{preset.label}</DropdownMenuItem>
{/each}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="destructive" size="sm" onclick={onClear} title="Clear all">
<Trash2 class="size-4" />
<span class="hidden sm:inline">Clear</span>
</Button>
{/if}
<ModeToggle />
</div>
</div>
</header>
<div class="mx-auto flex-1 w-full max-w-6xl px-3 py-4 sm:px-4 min-h-0 overflow-hidden flex flex-col">
{#if !hasInteracted}
<div class="flex items-center justify-center flex-1">
<Empty class="border-none">
<EmptyHeader>
<EmptyTitle>Welcome to Keycap Generator</EmptyTitle>
<EmptyDescription>
Create custom keycaps for multi-color 3D printing. Start a new project, load an existing one, or try a
preset.
</EmptyDescription>
</EmptyHeader>
<EmptyContent class="flex-col items-center gap-2">
<div class="flex flex-wrap justify-center gap-2">
<Button variant="outline" onclick={() => loadInput?.click()}>
<Upload class="size-4" />
Load Project
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>
<BookOpen class="size-4" />
Load Preset
<ChevronDown class="size-3" />
</Button>
{/snippet}
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="min-w-[160px]">
{#each presets as preset}
<DropdownMenuItem onclick={() => onLoadPreset(preset.value, true)}>{preset.label}</DropdownMenuItem>
{/each}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button onclick={startCleanProject}>
<Plus class="size-4" />
New Project
</Button>
</EmptyContent>
</Empty>
</div>
{:else}
<Tabs bind:value={tab} class="gap-4">
<div class="flex flex-wrap items-center gap-2">
<TabsList>
<TabsTrigger value="models">Models</TabsTrigger>
<TabsTrigger value="templates">Templates</TabsTrigger>
<TabsTrigger value="keys">Keys</TabsTrigger>
</TabsList>
<Button
class="ml-auto"
disabled={$app.keys.length === 0 || missingUploadModels.length > 0 || isGenerating}
onclick={onGenerate}
>
Generate
</Button>
</div>
<TabsContent value="models">
<KeycapModelsEditor />
</TabsContent>
<TabsContent value="templates">
<TemplateEditor />
</TabsContent>
<TabsContent value="keys">
<KeyEditor />
</TabsContent>
</Tabs>
{/if}
</div>
{#if isGenerating}
<ProcessingModal
title={currentKey ? `Generating: ${currentKey.name}` : 'Generating Keycaps'}
onCancel={onCancelGenerate}
current={progressCurrent}
total={progressTotal}
previewTemplate={currentTemplate ?? null}
previewContentBySymbolId={currentKey?.contentBySymbolId ?? {}}
previewWidthMm={currentModel?.widthMm ?? 0}
previewHeightMm={currentModel?.heightMm ?? 0}
/>
{/if}
<ModalContainer />
<footer class="mt-auto border-t bg-background/80 backdrop-blur">
<div
class="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-4 px-3 py-6 sm:flex-row sm:px-4"
>
<div class="flex items-center gap-4">
<Button
href="https://github.com/srwi/keycap_generator"
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="sm"
>
<Github class="h-4 w-4" />
<span>View on GitHub</span>
</Button>
<Button
href="https://github.com/srwi/keycap_generator"
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="sm"
>
<Star class="h-4 w-4 text-yellow-500" />
<span>Star</span>
</Button>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground">Donate:</span>
<Button
href="https://paypal.me/rumswinkel"
target="_blank"
rel="noopener noreferrer"
title="Support this project via PayPal"
variant="outline"
size="sm"
>
<Heart class="h-4 w-4 text-rose-500" />
<span>PayPal</span>
</Button>
<Button
href="https://ko-fi.com/stephanrwi"
target="_blank"
rel="noopener noreferrer"
title="Support this project via Ko-fi"
variant="outline"
size="sm"
>
<Coffee class="h-4 w-4 text-amber-500" />
<span>Ko-fi</span>
</Button>
</div>
</div>
</footer>
</div>
679b3bf6bThis file is pinned to the selected release. Download the original to open it in your design tool.