Skip to content
Draft
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
6 changes: 6 additions & 0 deletions app/components/ascii-art-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,12 @@ export function AsciiArtGenerator() {
}}
disabled={!program}
exportSettings={settings.export}
sourceCode={settings.source.code}
characterSet={settings.output.characterSet}
frameRate={settings.animation.frameRate}
esbuildService={esbuildService}
imageData={currentImageData}
frames={currentFrames}
/>
</div>
<div className="flex grow items-end p-3 pb-3">
Expand Down
60 changes: 49 additions & 11 deletions app/components/ascii-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,58 @@ export function AsciiPreview({
const [autoFit, setAutoFit] = useState(true)
const prevDimensionsRef = useRef(dimensions)

const containerSize = useSize(container)
// Mirror zoom/position in refs so rapid wheel events accumulate from the
// latest values rather than a stale render closure.
const zoomRef = useRef(zoomLevel)
const positionRef = useRef(position)
useEffect(() => {
zoomRef.current = zoomLevel
}, [zoomLevel])
useEffect(() => {
positionRef.current = position
}, [position])

const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
setAutoFit(false)
const containerSize = useSize(container)

const zoomFactor = 0.035 * (e.deltaY > 0 ? 1 : 1.1)
// Zoom toward the cursor (Figma-style) on scroll wheel. Attached as a
// non-passive native listener so preventDefault stops the container from
// also scrolling.
useEffect(() => {
if (!container) return

const onWheel = (e: WheelEvent) => {
e.preventDefault()
setAutoFit(false)

const rect = container.getBoundingClientRect()
// Cursor position relative to the container center (the transform origin).
const mouseX = e.clientX - rect.left - rect.width / 2
const mouseY = e.clientY - rect.top - rect.height / 2

const currentZoom = zoomRef.current
const currentPos = positionRef.current

const zoomFactor = 0.035 * (e.deltaY > 0 ? 1 : 1.1)
const newZoom =
e.deltaY < 0
? Math.min(currentZoom * (1 + zoomFactor), 3)
: Math.max(currentZoom / (1 + zoomFactor), 0.5)

const ratio = newZoom / currentZoom
const newPos = {
x: mouseX * (1 - ratio) + ratio * currentPos.x,
y: mouseY * (1 - ratio) + ratio * currentPos.y,
}

if (e.deltaY < 0) {
setZoomLevel((prev) => Math.min(prev * (1 + zoomFactor), 3))
} else {
setZoomLevel((prev) => Math.max(prev / (1 + zoomFactor), 0.5))
zoomRef.current = newZoom
positionRef.current = newPos
setZoomLevel(newZoom)
setPosition(newPos)
}
}

container.addEventListener('wheel', onWheel, { passive: false })
return () => container.removeEventListener('wheel', onWheel)
}, [container])

const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true)
Expand Down Expand Up @@ -314,7 +353,6 @@ export function AsciiPreview({
{/* ASCII preview container */}
<div
ref={setContainer}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
className="relative flex flex-1 items-center justify-center overflow-auto"
Expand All @@ -331,7 +369,7 @@ export function AsciiPreview({
</div>
)}
<div
className="duration-50 relative transform-gpu rounded-[1%] transition-transform ease-out"
className="duration-25 relative transform-gpu rounded-[1%] transition-transform ease-out"
style={{
transform: isExporting
? 'none'
Expand Down
74 changes: 74 additions & 0 deletions app/components/asset-export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import { useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { toast } from 'sonner'

import type { EsbuildService } from '~/hooks/use-esbuild'
import type { Cell, Program } from '~/lib/animation'
import { getColoredRows, getContent } from '~/lib/buffer-text'
import { generateReactComponentSource } from '~/lib/react-export'
import { glyphRunToPathData, loadAsciiFont, type Font } from '~/lib/svg-font'
import type { AsciiImageData } from '~/lib/types'
import { InputButton, InputNumber, InputSwitch } from '~/lib/ui/src'
import { InputSelect } from '~/lib/ui/src/components/InputSelect/InputSelect'

Expand Down Expand Up @@ -48,6 +51,14 @@ interface AssetExportProps {
backgroundColor: string
padding: number
}
// Needed to bundle a self-contained React component (program + runtime).
sourceCode: string
characterSet: string
frameRate: number
esbuildService: EsbuildService | null
// Processed 0–1 value grid(s) for image/GIF sources, baked into the export.
imageData: AsciiImageData | null
frames: AsciiImageData[] | null
}

export function AssetExport({
Expand All @@ -59,6 +70,12 @@ export function AssetExport({
dimensions,
disabled,
exportSettings,
sourceCode,
characterSet,
frameRate,
esbuildService,
imageData,
frames,
}: AssetExportProps) {
const [exportFormat, setExportFormat] = useState<ExportFormat>(
animationLength > 1 ? 'frames' : 'png',
Expand Down Expand Up @@ -667,6 +684,54 @@ export function AssetExport({
toast.success('Export complete!', { id: 'video-export' })
}

const buildReactComponentSource = async (): Promise<string | null> => {
if (!program || !esbuildService) return null
return generateReactComponentSource({
esbuildService,
code: sourceCode,
characterSet,
columns: dimensions.width,
rows: dimensions.height,
animationLength,
fps: frameRate,
settings: exportSettings,
imageData: imageData ?? undefined,
frames,
})
}

const exportReactComponent = async (mode: 'download' | 'copy') => {
if (!program) return
if (!esbuildService) {
toast('esbuild is still initializing — try again in a moment')
return
}
try {
setIsExporting(true)
toast.loading('Bundling React component…', { id: 'react-export' })
const source = await buildReactComponentSource()
if (!source) {
toast('Could not generate React component', { id: 'react-export' })
return
}
if (mode === 'download') {
const blob = new Blob([source], { type: 'text/typescript;charset=utf-8' })
saveAs(blob, 'ascii-art.tsx')
toast.success('React component downloaded', { id: 'react-export' })
} else {
await navigator.clipboard.writeText(source)
toast.success('React component copied to clipboard', { id: 'react-export' })
}
} catch (error) {
console.error('Error exporting React component:', error)
toast.error('Failed to export React component', { id: 'react-export' })
} finally {
setIsExporting(false)
}
}

const reactExportDisabled = isExporting || disabled || !esbuildService

// Copy with cmd+c
useHotkeys('meta+c', () => copyText(), { preventDefault: true }, [])

Expand Down Expand Up @@ -821,6 +886,15 @@ export function AssetExport({
Copy SVG
</InputButton>
</div>

<InputButton
variant="secondary"
className="w-full"
onClick={() => exportReactComponent('download')}
disabled={reactExportDisabled}
>
Download React
</InputButton>
</div>
</Container>
)
Expand Down
128 changes: 128 additions & 0 deletions app/lib/core/text-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* This Source Code Form is subject to the terms of the Apache License,
* v. 2.0. If a copy of the license was not distributed with this file, you can
* obtain one at https://github.com/ertdfgcvb/play.core/blob/master/LICENSE.
*
* Modified from https://github.com/ertdfgcvb/play.core
* Copyright ertdfgcvb (Andreas Gysin)
*/
import { invariant, type Cell, type Context } from '../animation'

export default function createRenderer() {
const backBuffer: Cell[] = []
let cols: number, rows: number

function render(context: Context, buffer: Cell[]): void {
const element = context.settings.element

invariant(!!element, 'Element is required')

// Detect resize and validate dimensions
if (context.rows !== rows || context.cols !== cols) {
// Validate dimensions
if (
context.rows <= 0 ||
context.cols <= 0 ||
!isFinite(context.rows) ||
!isFinite(context.cols)
) {
console.error(`Invalid dimensions: ${context.cols} x ${context.rows}`)
return
}

cols = context.cols
rows = context.rows
backBuffer.length = 0
}

// DOM rows update: expand lines if necessary
while (element.childElementCount < rows) {
const span = document.createElement('span')
span.style.display = 'block'
element.appendChild(span)
}

// DOM rows update: shorten lines if necessary
while (element.childElementCount > rows) {
const lastChild = element.lastChild
if (lastChild) element.removeChild(lastChild)
}

// A bit of a cumbersome render-loop…
// A few notes: the fastest way I found to render the image
// is by manually write the markup into the parent node via .innerHTML;
// creating a node via .createElement and then popluate it resulted
// remarkably slower (even if more elegant for the CSS handling below).
for (let j = 0; j < rows; j++) {
const offs = j * cols

// This check is faster than to force update the DOM.
// Buffer can be manually modified in pre, main and after
// with semi-arbitrary values…
// It is necessary to keep track of the previous state
// and specifically check if a change in style
// or char happened on the whole row.
let rowNeedsUpdate = false
for (let i = 0; i < cols; i++) {
const idx = i + offs
if (idx >= buffer.length) {
continue
}

const newCell = buffer[idx]
const oldCell = backBuffer[idx]
if (!isSameCell(newCell, oldCell)) {
rowNeedsUpdate = true
backBuffer[idx] = { ...newCell }
}
}

// Skip row if update is not necessary
if (rowNeedsUpdate === false) continue

let html = '' // Accumulates the markup
let openColor: string | null = null // colour of the currently open <span>, or null

for (let i = 0; i < cols; i++) {
const idx = i + offs
if (idx >= buffer.length) continue

const currCell = buffer[idx]
const color = currCell.color || null

// Open / close colour spans only when the colour changes, so a run of
// same-coloured cells shares a single span. Uncoloured cells fall back
// to the stock text colour set on the container.
if (color !== openColor) {
if (openColor !== null) html += '</span>'
if (color !== null) html += `<span style="color:${color}">`
openColor = color
}

html += currCell.char || ' '
}
if (openColor !== null) {
html += '</span>'
}

// Write the row
if (j < element.childElementCount) {
const childNode = element.childNodes[j] as HTMLSpanElement
childNode.innerHTML = html
}
}
}

// Move helper functions inside closure to access backBuffer
function isSameCell(cellA: Cell | undefined, cellB: Cell | undefined): boolean {
if (typeof cellA !== 'object') return false
if (typeof cellB !== 'object') return false
if (cellA?.char !== cellB?.char) return false
if (cellA?.color !== cellB?.color) return false
return true
}

return {
render,
}
}
Loading
Loading