Skip to content

Instantly share code, notes, and snippets.

@inad9300
Created December 29, 2022 10:07
Show Gist options
  • Save inad9300/f013a83d90d2b2bac3c73587c2e05800 to your computer and use it in GitHub Desktop.
Save inad9300/f013a83d90d2b2bac3c73587c2e05800 to your computer and use it in GitHub Desktop.
Canvas-based code visualizer
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code Scroll 📜</title>
</head>
<body>
<script>
const sampleSource = `
function f() {
if (true) {
// easy peasy
const n = 3
return 12 + n
} else if (false) {
return 'abc' + "xyz"
} else {
/* multi
line
comment */
return \`
multi
line
string
¡ this function s !
\`
}
}
alert('Hello, world!')
[1, 2].forEach(alert)
let year = prompt('In which year was ECMAScript-2015 specification published?', '')
if (year === 2015) alert('You are right!')
window.currentUser = {
name: "John"
}
class MyClass {
constructor() {}
method1() {}
method2() {
this.method1()
}
}
const myInstance = new MyClass()
myInstance.method3()`.trim() + '\n'
const syntaxColorTheme = {
comment: 'grey',
keyword: 'violet',
keywordLike: 'cornflowerblue',
literal: 'cadetblue',
string: 'goldenrod',
number: 'darkseagreen',
operator: 'indianred',
function: 'lightgoldenrodyellow'
}
const languageSyntax = [
{ type: 'keyword', pattern: /^(function|if|else|return|class)\b/ },
{ type: 'keywordLike', pattern: /^(const|let)\b/ },
{ type: 'literal', pattern: /^(true|false|this)\b/ },
{ type: 'function', pattern: /^[a-zA-Z_][a-zA-Z_0-9]*?\(/ },
{ type: 'number', pattern: /^[0-9]+/ },
{ type: 'string', pattern: /^'[^']*?'/ },
{ type: 'string', pattern: /^"[^']*?"/ },
{ type: 'string', pattern: /^`[^`]*?`/ },
{ type: 'comment', pattern: /^\/\/.*/ },
{ type: 'comment', pattern: /^\/\*(.|\n)*?\*\// },
{ type: 'operator', pattern: /^[.+-/*!=:]/ },
]
document.body.style.margin = '0'
document.body.style.backgroundColor = '#777'
// TODO: Refactor!
// TODO: Fix selection when canvas is scrolled.
// TODO: Fix selection when scroll is not at exact line boundary.
// TODO: Fix selection from higher to lower coordinates.
// TODO: Make selection adapt to line shape.
// TODO: Check https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas.
// const offscreenCanvas = document.createElement('canvas')
// const offscreenContext = canvas.getContext('2d')
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
const canvasWidth = 512
const canvasHeight = 512
const canvasPadding = 8
const dpr = window.devicePixelRatio
canvas.width = canvasWidth * dpr
canvas.height = canvasHeight * dpr
canvas.style.width = canvasWidth + 'px'
canvas.style.height = canvasHeight + 'px'
canvas.style.padding = canvasPadding + 'px'
canvas.style.borderRadius = '6px'
canvas.style.backgroundColor = '#2e2e32'
canvas.style.boxShadow = '0 5px 15px rgb(0 0 0 / 20%)'
context.scale(dpr, dpr)
const fontSize = 14
const fontFamily = 'Menlo, monospace'
context.font = fontSize + 'px ' + fontFamily
context.textBaseline = 'top'
const charMetrics = context.measureText('a')
const charWidth = charMetrics.width
const lineHeight = charMetrics.fontBoundingBoxAscent + charMetrics.fontBoundingBoxDescent + 2
const lineNumberColor = '#777'
const defaultTextColor = '#eee'
function parse(source) {
const drawInstructions = {}
function pushInstruction(color, text, line, column) {
if (!drawInstructions[color]) {
drawInstructions[color] = []
}
drawInstructions[color].push({ text, line, column })
}
let lineNumber = 1
let column = 0
for (let i = 0; i < source.length; ++i) {
const char = source[i]
if (char === ' ') {
column++
continue
}
else if (char === '\n') {
pushInstruction(lineNumberColor, ('' + lineNumber).padStart(2, ' '), lineNumber - 1, 0)
lineNumber++
column = 0
continue
}
else {
const sourceLeft = source.slice(i)
let matchFound = false
for (const x of languageSyntax) {
const match = sourceLeft.match(x.pattern)
if (match) {
const matchLines = match[0].split('\n')
if (matchLines.length === 1) {
pushInstruction(syntaxColorTheme[x.type], matchLines[0], lineNumber - 1, column + 3)
column += match[0].length
i += match[0].length - 1
} else {
for (const matchLine of matchLines) {
pushInstruction(syntaxColorTheme[x.type], matchLine, lineNumber - 1, column + 3)
pushInstruction(lineNumberColor, ('' + lineNumber).padStart(2, ' '), lineNumber - 1, 0)
lineNumber++
column = 0
}
i += match[0].length
}
matchFound = true
break
}
}
if (!matchFound) {
pushInstruction(defaultTextColor, char, lineNumber - 1, column + 3)
column++
}
}
}
return drawInstructions
}
let selecting = false, selectionStartLine, selectionStartColumn, selectionEndLine, selectionEndColumn
function draw(textInstructionsByColor, xOffset, yOffset) {
context.clearRect(0, 0, canvas.width, canvas.height)
if (selecting) {
context.fillStyle = '#444'
let actualStartX = selectionStartColumn * charWidth
if (selectionStartColumn > selectionEndColumn) actualStartX += charWidth
let actualStartY = selectionStartLine * lineHeight
if (selectionStartLine > selectionEndLine) actualStartY += lineHeight
let actualSizeX = (selectionEndColumn - selectionStartColumn) * charWidth
actualSizeX = selectionStartColumn <= selectionEndColumn ? actualSizeX + charWidth : actualSizeX - charWidth
let actualSizeY = (selectionEndLine - selectionStartLine) * lineHeight
actualSizeY = selectionStartLine <= selectionEndLine ? actualSizeY + lineHeight : actualSizeY - lineHeight
context.fillRect(actualStartX, actualStartY, actualSizeX, actualSizeY)
}
for (const color in textInstructionsByColor) {
context.fillStyle = color
for (const instr of textInstructionsByColor[color]) {
const y = yOffset + instr.line * lineHeight
if (y <= canvasHeight && y + lineHeight >= 0) {
const x = xOffset + instr.column * charWidth
context.fillText(instr.text, x, y)
}
}
}
}
let yOffset = 2
let xOffset = 0
const sourceLines = sampleSource.split('\n')
const maxXOffset = Math.max(...sourceLines.map(line => line.length)) * charWidth - (charWidth * (1 - 3))
const maxYOffset = (sourceLines.length - 1) * lineHeight - (lineHeight * 1)
console.time('parse')
const drawInstructions = parse(sampleSource)
console.timeEnd('parse')
console.time('1000 clear and draw')
for (let i = 0; i < 1000; ++i) {
draw(drawInstructions, xOffset, yOffset)
}
console.timeEnd('1000 clear and draw') // ~170ms
canvas.addEventListener('mousedown', ev => {
const { x, y } = canvas.getBoundingClientRect()
selecting = true
selectionStartLine = Math.floor((ev.clientY - (y + canvasPadding)) / lineHeight)
selectionStartColumn = Math.floor((ev.clientX - (x + canvasPadding)) / charWidth)
selectionEndLine = selectionStartLine
selectionEndColumn = selectionStartColumn
draw(drawInstructions, xOffset, yOffset)
})
window.addEventListener('mousemove', ev => {
if (selecting) {
const { x, y } = canvas.getBoundingClientRect()
selectionEndLine = Math.floor((ev.clientY - (y + canvasPadding)) / lineHeight)
selectionEndColumn = Math.floor((ev.clientX - (x + canvasPadding)) / charWidth)
draw(drawInstructions, xOffset, yOffset)
}
})
let selection = ''
window.addEventListener('mouseup', ev => {
selecting = false
if (ev.target != canvas) {
selection = ''
draw(drawInstructions, xOffset, yOffset)
} else {
const selectedLines = sourceLines.slice(selectionStartLine, selectionEndLine + 1)
const lastIdx = selectedLines.length - 1
if (selectionStartLine === selectionEndLine) {
selectedLines[0] = selectedLines[0].slice(selectionStartColumn - 3, selectionEndColumn - 3 + 1)
} else {
selectedLines[0] = selectedLines[0].slice(selectionStartColumn - 3)
selectedLines[lastIdx] = selectedLines[lastIdx].slice(0, selectionEndColumn - 3 + 1)
}
selection = selectedLines.join('\n')
}
}, true)
window.addEventListener('keydown', ev => {
if ((ev.metaKey || ev.ctrlKey) && ev.code === 'KeyC') {
navigator.clipboard.writeText(selection)
}
})
window.addEventListener('wheel', ev => {
ev.preventDefault()
const priorYOffset = yOffset
const priorXOffset = xOffset
yOffset = Math.min(2, yOffset - ev.deltaY)
if (-yOffset > maxYOffset) {
yOffset = -maxYOffset
}
xOffset = Math.min(0, xOffset - ev.deltaX)
if (-xOffset > maxXOffset) {
xOffset = -maxXOffset
}
if (yOffset !== priorYOffset || xOffset !== priorXOffset) {
draw(drawInstructions, xOffset, yOffset)
}
}, { passive: false })
document.body.style.height = '100vh'
document.body.style.display = 'flex'
document.body.style.alignItems = 'center'
document.body.style.justifyContent = 'center'
document.body.append(canvas)
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment