Created
December 29, 2022 10:07
-
-
Save inad9300/f013a83d90d2b2bac3c73587c2e05800 to your computer and use it in GitHub Desktop.
Canvas-based code visualizer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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