It's just a kinetic sculpture that's been modelled using some few lines of GLSL.
A Pen by Matthias Hurrle on CodePen.
| <canvas id="canvas"></canvas> | |
| <textarea id="codeEditor" class="editor" spellcheck="false" autocorrect="off" autocapitalize="off" translate="no" oninput="render()"></textarea> | |
| <pre id="error"></pre> | |
| <div id="indicator"></div> | |
| <div id="controls"> | |
| <div class="controls"> | |
| <input id="btnToggleView" class="icon" type="checkbox" name="toggleView" onclick="toggleView()"> | |
| <input id="btnToggleResolution" class="icon" type="checkbox" name="toggleResolution" onchange="toggleResolution()"> | |
| <input id="btnReset" class="icon" type="checkbox" name="reset" onclick="reset()"> | |
| </div> | |
| </div> | |
| <script type="x-shader/x-fragment">#version 300 es | |
| /********* | |
| * made by Matthias Hurrle (@atzedent) | |
| */ | |
| precision highp float; | |
| out vec4 O; | |
| uniform float time; | |
| uniform vec2 resolution; | |
| uniform vec2 touch; | |
| uniform int pointerCount; | |
| #define P pointerCount | |
| #define mouse (touch/R) | |
| #define FC gl_FragCoord.xy | |
| #define R resolution | |
| #define T time | |
| #define N normalize | |
| #define MN min(R.x,R.y) | |
| #define S smoothstep | |
| #define rot(a) mat2(cos((a)-vec4(0,11,33,0))) | |
| float box(vec3 p, vec3 s, float r) { | |
| p=abs(p)-s+r; | |
| return length(max(p,.0))+min(.0,max(max(p.x,p.y),p.z))-r; | |
| } | |
| float map(vec3 p) { | |
| const float n=5.; | |
| vec3 q=p; | |
| q.x-=clamp(round(q.x/n),-3.,3.)*n; | |
| p.x-=2.5; | |
| float id=round(p.x/n); | |
| p.x-=clamp(round(p.x/n),-3.,2.)*n; | |
| float d=max(box(q,vec3(.3,5,5),.1), -(length(q)-2.2)); | |
| d=min(d,length(p)-1.-.9*cos(1.25*T-id*.75)); | |
| return d; | |
| } | |
| vec3 norm(vec3 p) { | |
| float h=1e-3; vec2 k=vec2(-1,1); | |
| return N( | |
| k.xyy*map(p+k.xyy*h)+ | |
| k.yxy*map(p+k.yxy*h)+ | |
| k.yyx*map(p+k.yyx*h)+ | |
| k.xxx*map(p+k.xxx*h) | |
| ); | |
| } | |
| void cam(inout vec3 p) { | |
| if (P>0) { | |
| p.yz*=rot(-mouse.y*6.3+3.14); | |
| p.xz*=rot(3.14-mouse.x*6.3); | |
| } else { | |
| p.yz*=rot(-(sin(T*.2)*.5+.5)); | |
| p.xz*=rot(-T*.125); | |
| } | |
| } | |
| float occ(vec3 p, vec3 n, float d) { | |
| return clamp(map(p+n*d)/d,.0,1.); | |
| } | |
| vec3 dir(vec2 uv, vec3 p, vec3 t, float z) { | |
| vec3 up=vec3(0,1,0), | |
| f=N(t-p), | |
| r=N(cross(up,f)), | |
| u=N(cross(f,r)); | |
| return mat3(r,u,f)*N(vec3(uv,z)); | |
| } | |
| vec3 render(vec2 uv) { | |
| vec3 col=vec3(0), | |
| p=vec3(0,3,-40), | |
| rd=dir(uv,p,vec3(2,3,0),1.); | |
| cam(p); cam(rd); | |
| vec3 lp=vec3(-1,2,-13); | |
| float bnz=.0; | |
| for (float i=.0; i<400.; i++) { | |
| float d=map(p); | |
| if (abs(d)<1e-3) { | |
| if (bnz++>2.) break; | |
| vec3 n=norm(p), l=N(lp-p); | |
| if (dot(l,n)<.0) l=-l; | |
| float dif=clamp(dot(l,n),.0,1.), ao=occ(p,n,1.5)*occ(p,n,1.), | |
| fre=pow(clamp(1.+dot(rd,n),.0,1.),5.); | |
| col+=.08*dif*ao+1.5*fre; | |
| col+=pow(clamp(dot(reflect(rd,n),l),.0,1.),5.); | |
| rd=reflect(rd,n); | |
| d=5e-2; | |
| vec2 st=rd.xy; | |
| st.y=abs(abs(abs(st.y)-.5)-.25)-.125; | |
| col+=.2*S(-.01,.01,st.y)*-n.x; | |
| } | |
| if (d>36.){ | |
| break; | |
| } | |
| p+=rd*d; | |
| } | |
| col=mix(vec3(.1,-.1,.3),vec3(1.5,.7,-.6),col); | |
| col=sqrt(tanh(S(-.1,1.1,col*col))); | |
| col=vec3(dot(col,vec3(.21,.71,.07))); | |
| col=max(col,.08); | |
| return col; | |
| } | |
| #define AA | |
| void main() { | |
| vec2 uv=(FC-.5*R)/MN; | |
| vec3 col=render(uv); | |
| #ifdef AA | |
| if (fwidth(col.r)>1e-2) { | |
| for (float x=.0; x<=1.; x++) { | |
| for (float y=.0; y<=1.; y++) | |
| col+=render(uv+(vec2(x,y)-.5)/R); | |
| } | |
| col/=5.; | |
| } | |
| #endif | |
| O=vec4(col,1); | |
| }</script> |
It's just a kinetic sculpture that's been modelled using some few lines of GLSL.
A Pen by Matthias Hurrle on CodePen.
| /********* | |
| * made by Matthias Hurrle (@atzedent) | |
| */ | |
| let editMode = false // set to false to hide the code editor on load | |
| let resolution = .5 // set 1 for full resolution or to .5 to start with half resolution on load | |
| let renderDelay = 1000 // delay in ms before rendering the shader after a change | |
| let dpr = Math.max(1, resolution * window.devicePixelRatio) | |
| let frm, source, editor, store, renderer, pointers | |
| const shaderId = 'LEpQzjM' | |
| window.onload = init | |
| function resize() { | |
| const { innerWidth: width, innerHeight: height } = window | |
| canvas.width = width * dpr | |
| canvas.height = height * dpr | |
| if (renderer) { | |
| renderer.updateScale(dpr) | |
| } | |
| } | |
| function toggleView() { | |
| editor.hidden = btnToggleView.checked | |
| canvas.style.setProperty('--canvas-z-index', btnToggleView.checked ? 0 : -1) | |
| } | |
| function reset() { | |
| let shader = source | |
| editor.text = shader ? shader.textContent : renderer.defaultSource | |
| store.putShaderSource(shaderId, editor.text) | |
| renderThis() | |
| } | |
| function toggleResolution() { | |
| resolution = btnToggleResolution.checked ? .5 : 1 | |
| dpr = Math.max(1, resolution * window.devicePixelRatio) | |
| pointers.updateScale(dpr) | |
| resize() | |
| } | |
| function loop(now) { | |
| renderer.updateMouse(pointers.first) | |
| renderer.updatePointerCount(pointers.count) | |
| renderer.updatePointerCoords(pointers.coords) | |
| renderer.updateMove(pointers.move) | |
| renderer.render(now) | |
| frm = requestAnimationFrame(loop) | |
| } | |
| function renderThis() { | |
| editor.clearError() | |
| store.putShaderSource(shaderId, editor.text) | |
| const result = renderer.test(editor.text) | |
| if (result) { | |
| editor.setError(result) | |
| } else { | |
| renderer.updateShader(editor.text) | |
| } | |
| cancelAnimationFrame(frm) // Always cancel the previous frame! | |
| loop(0) | |
| } | |
| const debounce = (fn, delay) => { | |
| let timerId | |
| return (...args) => { | |
| clearTimeout(timerId) | |
| timerId = setTimeout(() => fn.apply(this, args), delay) | |
| } | |
| } | |
| const render = debounce(renderThis, renderDelay) | |
| function init() { | |
| source = document.querySelector("script[type='x-shader/x-fragment']") | |
| document.title = "Kinetic" | |
| renderer = new Renderer(canvas, dpr) | |
| pointers = new PointerHandler(canvas, dpr) | |
| store = new Store(window.location) | |
| editor = new Editor(codeEditor, error, indicator) | |
| editor.text = source.textContent | |
| renderer.setup() | |
| renderer.init() | |
| if (!editMode) { | |
| btnToggleView.checked = true | |
| toggleView() | |
| } | |
| if (resolution === .5) { | |
| btnToggleResolution.checked = true | |
| toggleResolution() | |
| } | |
| canvas.addEventListener('shader-error', e => editor.setError(e.detail)) | |
| resize() | |
| if (renderer.test(source.textContent) === null) { | |
| renderer.updateShader(source.textContent) | |
| } | |
| loop(0) | |
| window.onresize = resize | |
| window.addEventListener("keydown", e => { | |
| if (e.key === "L" && e.ctrlKey) { | |
| e.preventDefault() | |
| btnToggleView.checked = !btnToggleView.checked | |
| toggleView() | |
| } | |
| }) | |
| } | |
| class Renderer { | |
| #vertexSrc = "#version 300 es\nprecision highp float;\nin vec4 position;\nvoid main(){gl_Position=position;}" | |
| #fragmtSrc = "#version 300 es\nprecision highp float;\nout vec4 O;\nuniform float time;\nuniform vec2 resolution;\nvoid main() {\n\tvec2 uv=gl_FragCoord.xy/resolution;\n\tO=vec4(uv,sin(time)*.5+.5,1);\n}" | |
| #vertices = [-1, 1, -1, -1, 1, 1, 1, -1] | |
| constructor(canvas, scale) { | |
| this.canvas = canvas | |
| this.scale = scale | |
| this.gl = canvas.getContext("webgl2") | |
| this.gl.viewport(0, 0, canvas.width * scale, canvas.height * scale) | |
| this.shaderSource = this.#fragmtSrc | |
| this.mouseMove = [0, 0] | |
| this.mouseCoords = [0, 0] | |
| this.pointerCoords = [0, 0] | |
| this.nbrOfPointers = 0 | |
| } | |
| get defaultSource() { return this.#fragmtSrc } | |
| updateShader(source) { | |
| this.reset() | |
| this.shaderSource = source | |
| this.setup() | |
| this.init() | |
| } | |
| updateMove(deltas) { | |
| this.mouseMove = deltas | |
| } | |
| updateMouse(coords) { | |
| this.mouseCoords = coords | |
| } | |
| updatePointerCoords(coords) { | |
| this.pointerCoords = coords | |
| } | |
| updatePointerCount(nbr) { | |
| this.nbrOfPointers = nbr | |
| } | |
| updateScale(scale) { | |
| this.scale = scale | |
| this.gl.viewport(0, 0, this.canvas.width * scale, this.canvas.height * scale) | |
| } | |
| compile(shader, source) { | |
| const gl = this.gl | |
| gl.shaderSource(shader, source) | |
| gl.compileShader(shader) | |
| if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { | |
| console.error(gl.getShaderInfoLog(shader)) | |
| this.canvas.dispatchEvent(new CustomEvent('shader-error', { detail: gl.getShaderInfoLog(shader) })) | |
| } | |
| } | |
| test(source) { | |
| let result = null | |
| const gl = this.gl | |
| const shader = gl.createShader(gl.FRAGMENT_SHADER) | |
| gl.shaderSource(shader, source) | |
| gl.compileShader(shader) | |
| if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { | |
| result = gl.getShaderInfoLog(shader) | |
| } | |
| if (gl.getShaderParameter(shader, gl.DELETE_STATUS)) { | |
| gl.deleteShader(shader) | |
| } | |
| return result | |
| } | |
| reset() { | |
| const { gl, program, vs, fs } = this | |
| if (!program || gl.getProgramParameter(program, gl.DELETE_STATUS)) return | |
| if (gl.getShaderParameter(vs, gl.DELETE_STATUS)) { | |
| gl.detachShader(program, vs) | |
| gl.deleteShader(vs) | |
| } | |
| if (gl.getShaderParameter(fs, gl.DELETE_STATUS)) { | |
| gl.detachShader(program, fs) | |
| gl.deleteShader(fs) | |
| } | |
| gl.deleteProgram(program) | |
| } | |
| createCubeMap() { | |
| const { gl } = this | |
| const cubeMap = gl.createTexture() | |
| gl.bindTexture(gl.TEXTURE_CUBE_MAP, cubeMap) | |
| const imgpath='https://assets.codepen.io/4386748' | |
| const faces = [ | |
| [gl.TEXTURE_CUBE_MAP_POSITIVE_X, '01posx.jpg'], | |
| [gl.TEXTURE_CUBE_MAP_NEGATIVE_X, '01negx.jpg'], | |
| [gl.TEXTURE_CUBE_MAP_POSITIVE_Y, '01posy.jpg'], | |
| [gl.TEXTURE_CUBE_MAP_NEGATIVE_Y, '01negy.jpg'], | |
| [gl.TEXTURE_CUBE_MAP_POSITIVE_Z, '01posz.jpg'], | |
| [gl.TEXTURE_CUBE_MAP_NEGATIVE_Z, '01negz.jpg'], | |
| ] | |
| for (let [target, url] of faces) { | |
| const level = 0 | |
| const internalFormat = gl.RGBA | |
| const width = 512 | |
| const height = 512 | |
| const format = gl.RGBA | |
| const type = gl.UNSIGNED_BYTE | |
| gl.texImage2D(target, level, internalFormat, width, height, 0, format, type, null) | |
| const image = new Image() | |
| image.crossOrigin = 'anonymous' | |
| image.onload = () => { | |
| gl.bindTexture(gl.TEXTURE_CUBE_MAP, cubeMap) | |
| gl.texImage2D(target, level, internalFormat, format, type, image) | |
| } | |
| image.src = `${imgpath}/${url}?width=512&height=512&format=auto` | |
| } | |
| gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.NEAREST) | |
| } | |
| setup() { | |
| const gl = this.gl | |
| this.createCubeMap() | |
| this.vs = gl.createShader(gl.VERTEX_SHADER) | |
| this.fs = gl.createShader(gl.FRAGMENT_SHADER) | |
| this.compile(this.vs, this.#vertexSrc) | |
| this.compile(this.fs, this.shaderSource) | |
| this.program = gl.createProgram() | |
| gl.attachShader(this.program, this.vs) | |
| gl.attachShader(this.program, this.fs) | |
| gl.linkProgram(this.program) | |
| if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) { | |
| console.error(gl.getProgramInfoLog(this.program)) | |
| } | |
| } | |
| init() { | |
| const { gl, program } = this | |
| this.buffer = gl.createBuffer() | |
| gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer) | |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(this.#vertices), gl.STATIC_DRAW) | |
| const position = gl.getAttribLocation(program, "position") | |
| gl.enableVertexAttribArray(position) | |
| gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0) | |
| program.resolution = gl.getUniformLocation(program, "resolution") | |
| program.time = gl.getUniformLocation(program, "time") | |
| program.move = gl.getUniformLocation(program, "move") | |
| program.touch = gl.getUniformLocation(program, "touch") | |
| program.pointerCount = gl.getUniformLocation(program, "pointerCount") | |
| program.pointers = gl.getUniformLocation(program, "pointers") | |
| program.cubeMap = gl.getUniformLocation(program, "cubeMap") | |
| } | |
| render(now = 0) { | |
| const { gl, program, buffer, canvas, mouseMove, mouseCoords, pointerCoords, nbrOfPointers } = this | |
| if (!program || gl.getProgramParameter(program, gl.DELETE_STATUS)) return | |
| gl.clearColor(0, 0, 0, 1) | |
| gl.clear(gl.COLOR_BUFFER_BIT) | |
| gl.useProgram(program) | |
| gl.bindBuffer(gl.ARRAY_BUFFER, buffer) | |
| gl.uniform2f(program.resolution, canvas.width, canvas.height) | |
| gl.uniform1f(program.time, now * 1e-3) | |
| gl.uniform2f(program.move, ...mouseMove) | |
| gl.uniform2f(program.touch, ...mouseCoords) | |
| gl.uniform1i(program.pointerCount, nbrOfPointers) | |
| gl.uniform2fv(program.pointers, pointerCoords) | |
| gl.uniform1i(program.cubeMap, 0) | |
| gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) | |
| } | |
| } | |
| class Store { | |
| constructor(key) { | |
| this.key = key | |
| this.store = window.localStorage | |
| } | |
| #ownShadersKey = 'ownShaders' | |
| #StorageType = Object.freeze({ | |
| shader: 'fragmentSource', | |
| config: 'config' | |
| }) | |
| #getKeyPrefix(type) { | |
| return `${type}${btoa(this.key)}` | |
| } | |
| #getKey(type, name) { | |
| return `${this.#getKeyPrefix(type)}${btoa(name)}` | |
| } | |
| putShaderSource(name, source) { | |
| const storageType = this.#StorageType.shader | |
| this.store.setItem(this.#getKey(storageType, name), source) | |
| } | |
| getShaderSource(name) { | |
| const storageType = this.#StorageType.shader | |
| return this.store.getItem(this.#getKey(storageType, name)) | |
| } | |
| deleteShaderSource(name) { | |
| const storageType = this.#StorageType.shader | |
| this.store.removeItem(this.#getKey(storageType, name)) | |
| } | |
| /** @returns {{title:string, uuid:string}[]} */ | |
| getOwnShaders() { | |
| const storageType = this.#StorageType.config | |
| const result = this.store.getItem(this.#getKey(storageType, this.#ownShadersKey)) | |
| return result ? JSON.parse(result) : [] | |
| } | |
| /** @param {{title:string, uuid:string}[]} shader */ | |
| putOwnShader(shader) { | |
| const ownShaders = this.getOwnShaders() | |
| const storageType = this.#StorageType.config | |
| const index = ownShaders.findIndex((s) => s.uuid === shader.uuid) | |
| if (index === -1) { | |
| ownShaders.push(shader) | |
| } else { | |
| ownShaders[index] = shader | |
| } | |
| this.store.setItem(this.#getKey(storageType, this.#ownShadersKey), JSON.stringify(ownShaders)) | |
| } | |
| deleteOwnShader(uuid) { | |
| const ownShaders = this.getOwnShaders() | |
| const storageType = this.#StorageType.config | |
| this.store.setItem(this.#getKey(storageType, this.#ownShadersKey), JSON.stringify(ownShaders.filter((s) => s.uuid !== uuid) )) | |
| this.deleteShaderSource(uuid) | |
| } | |
| /** @param {string[]} keep The names of the shaders to keep*/ | |
| cleanup(keep=[]) { | |
| const storageType = this.#StorageType.shader | |
| const ownShaders = this.getOwnShaders().map((s) => this.#getKey(storageType, s.uuid)) | |
| const premadeShaders = keep.map((name) => this.#getKey(storageType, name)) | |
| const keysToKeep = [...ownShaders, ...premadeShaders] | |
| const result = [] | |
| for (let i = 0; i < this.store.length; i++) { | |
| const key = this.store.key(i) | |
| if (key.startsWith(this.#getKeyPrefix(this.#StorageType.shader)) && !keysToKeep.includes(key)) { | |
| result.push(key) | |
| } | |
| } | |
| result.forEach((key) => this.store.removeItem(key)) | |
| } | |
| } | |
| class PointerHandler { | |
| constructor(element, scale) { | |
| this.scale = scale | |
| this.active = false | |
| this.pointers = new Map() | |
| this.lastCoords = [0,0] | |
| this.moves = [0,0] | |
| const map = (element, scale, x, y) => [x * scale, element.height - y * scale] | |
| element.addEventListener("pointerdown", (e) => { | |
| this.active = true | |
| this.pointers.set(e.pointerId, map(element, this.getScale(), e.clientX, e.clientY)) | |
| }) | |
| element.addEventListener("pointerup", (e) => { | |
| if (this.count === 1) { | |
| this.lastCoords = this.first | |
| } | |
| this.pointers.delete(e.pointerId) | |
| this.active = this.pointers.size > 0 | |
| }) | |
| element.addEventListener("pointerleave", (e) => { | |
| if (this.count === 1) { | |
| this.lastCoords = this.first | |
| } | |
| this.pointers.delete(e.pointerId) | |
| this.active = this.pointers.size > 0 | |
| }) | |
| element.addEventListener("pointermove", (e) => { | |
| if (!this.active) return | |
| this.lastCoords = [e.clientX, e.clientY] | |
| this.pointers.set(e.pointerId, map(element, this.getScale(), e.clientX, e.clientY)) | |
| this.moves = [this.moves[0]+e.movementX, this.moves[1]+e.movementY] | |
| }) | |
| } | |
| getScale() { | |
| return this.scale | |
| } | |
| updateScale(scale) { this.scale = scale } | |
| reset() { | |
| this.pointers.clear() | |
| this.active = false | |
| this.moves = [0,0] | |
| } | |
| get count() { | |
| return this.pointers.size | |
| } | |
| get move() { | |
| return this.moves | |
| } | |
| get coords() { | |
| return this.pointers.size > 0 ? Array.from(this.pointers.values()).map((p) => [...p]).flat() : [0, 0] | |
| } | |
| get first() { | |
| return this.pointers.values().next().value || this.lastCoords | |
| } | |
| } | |
| class Editor { | |
| constructor(textarea, errorfield, errorindicator) { | |
| this.textarea = textarea | |
| this.errorfield = errorfield | |
| this.errorindicator = errorindicator | |
| textarea.addEventListener('keydown', this.handleKeydown.bind(this)) | |
| textarea.addEventListener('scroll', this.handleScroll.bind(this)) | |
| } | |
| get hidden() { return this.textarea.classList.contains('hidden') } | |
| set hidden(value) { value ? this.#hide() : this.#show() } | |
| get text() { return this.textarea.value } | |
| set text(value) { this.textarea.value = value } | |
| get scrollTop() { return this.textarea.scrollTop } | |
| set scrollTop(value) { this.textarea.scrollTop = value } | |
| setError(message) { | |
| this.errorfield.innerHTML = message | |
| this.errorfield.classList.add('opaque') | |
| const match = message.match(/ERROR: \d+:(\d+):/) | |
| const lineNumber = match ? parseInt(match[1]) : 0 | |
| const overlay = document.createElement('pre') | |
| overlay.classList.add('overlay') | |
| overlay.textContent = '\n'.repeat(lineNumber) | |
| document.body.appendChild(overlay) | |
| const offsetTop = parseInt(getComputedStyle(overlay).height) | |
| this.errorindicator.style.setProperty('--top', offsetTop + 'px') | |
| this.errorindicator.style.visibility = 'visible' | |
| document.body.removeChild(overlay) | |
| } | |
| clearError() { | |
| this.errorfield.textContent = '' | |
| this.errorfield.classList.remove('opaque') | |
| this.errorfield.blur() | |
| this.errorindicator.style.visibility = 'hidden' | |
| } | |
| focus() { | |
| this.textarea.focus() | |
| } | |
| #hide() { | |
| for (const el of [this.errorindicator, this.errorfield, this.textarea]) { | |
| el.classList.add('hidden') | |
| } | |
| } | |
| #show() { | |
| for (const el of [this.errorindicator, this.errorfield, this.textarea]) { | |
| el.classList.remove('hidden') | |
| } | |
| this.focus() | |
| } | |
| handleScroll() { | |
| this.errorindicator.style.setProperty('--scroll-top', `${this.textarea.scrollTop}px`) | |
| } | |
| handleKeydown(event) { | |
| if (event.key === "Tab") { | |
| event.preventDefault() | |
| this.handleTabKey(event.shiftKey) | |
| } else if (event.key === "Enter") { | |
| event.preventDefault() | |
| this.handleEnterKey() | |
| } | |
| } | |
| handleTabKey(shiftPressed) { | |
| if (this.#getSelectedText() !== "") { | |
| if (shiftPressed) { | |
| this.#unindentSelectedText() | |
| return | |
| } | |
| this.#indentSelectedText() | |
| } else { | |
| this.#indentAtCursor() | |
| } | |
| } | |
| #getSelectedText() { | |
| const editor = this.textarea | |
| const start = editor.selectionStart | |
| const end = editor.selectionEnd | |
| return editor.value.substring(start, end) | |
| } | |
| #indentAtCursor() { | |
| const editor = this.textarea | |
| const cursorPos = editor.selectionStart | |
| document.execCommand('insertText', false, '\t') | |
| editor.selectionStart = editor.selectionEnd = cursorPos + 1 | |
| } | |
| #indentSelectedText() { | |
| const editor = this.textarea | |
| const cursorPos = editor.selectionStart | |
| const selectedText = this.#getSelectedText() | |
| const lines = selectedText.split('\n') | |
| const indentedText = lines.map(line => '\t' + line).join('\n') | |
| document.execCommand('insertText', false, indentedText) | |
| editor.selectionStart = cursorPos | |
| } | |
| #unindentSelectedText() { | |
| const editor = this.textarea | |
| const cursorPos = editor.selectionStart | |
| const selectedText = this.#getSelectedText() | |
| const lines = selectedText.split('\n') | |
| const indentedText = lines.map(line => line.replace(/^\t/, '').replace(/^ /, '')).join('\n') | |
| document.execCommand('insertText', false, indentedText) | |
| editor.selectionStart = cursorPos | |
| } | |
| handleEnterKey() { | |
| const editor = this.textarea | |
| const visibleTop = editor.scrollTop | |
| const cursorPosition = editor.selectionStart | |
| let start = cursorPosition - 1 | |
| while (start >= 0 && editor.value[start] !== '\n') { | |
| start-- | |
| } | |
| let newLine = '' | |
| while (start < cursorPosition - 1 && (editor.value[start + 1] === ' ' || editor.value[start + 1] === '\t')) { | |
| newLine += editor.value[start + 1] | |
| start++ | |
| } | |
| document.execCommand('insertText', false, '\n' + newLine) | |
| editor.selectionStart = editor.selectionEnd = cursorPosition + 1 + newLine.length | |
| editor.scrollTop = visibleTop // Prevent the editor from scrolling | |
| const lineHeight = editor.scrollHeight / editor.value.split('\n').length | |
| const line = editor.value.substring(0, cursorPosition).split('\n').length | |
| // Do the actual layout calculation in order to get the correct scroll position | |
| const visibleBottom = editor.scrollTop + editor.clientHeight | |
| const lineTop = lineHeight * (line - 1) | |
| const lineBottom = lineHeight * (line + 2) | |
| // If the cursor is outside the visible range, scroll the editor | |
| if (lineTop < visibleTop) editor.scrollTop = lineTop | |
| if (lineBottom > visibleBottom) editor.scrollTop = lineBottom - editor.clientHeight | |
| } | |
| } |
| ::-webkit-scrollbar { | |
| width: 0.625rem; | |
| height: 0.625rem; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #111; | |
| border-radius: 0.3125rem; | |
| box-shadow: inset 0.125rem 0.125rem 0.125rem rgba(255, 255, 255, 0.25), | |
| inset -0.125rem -0.125rem 0.125rem rgba(0, 0, 0, 0.25); | |
| cursor: default; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #333; | |
| } | |
| ::selection { | |
| background: #fff; | |
| color: #333; | |
| } | |
| html, | |
| body { | |
| height: 100vh; | |
| height: 100dvh; | |
| margin: 0; | |
| overflow: hidden; | |
| } | |
| body { | |
| display: grid; | |
| grid-template-rows: calc(100dvh - 4rem) 4rem; | |
| font-family: system-ui, sans-serif; | |
| } | |
| canvas, | |
| .editor, | |
| #controls { | |
| grid-row: 1; | |
| grid-column: 1; | |
| } | |
| canvas { | |
| --canvas-z-index: -1; | |
| width: 100%; | |
| height: auto; | |
| object-fit: contain; | |
| background: black; | |
| touch-action: none; | |
| z-index: var(--canvas-z-index); | |
| } | |
| .editor, | |
| .overlay, | |
| #error { | |
| font-family: 'Courier New', Courier, monospace; | |
| background: repeating-linear-gradient(0deg, #000a, #1119, #000a .25rem); | |
| padding: 1em; | |
| } | |
| .editor { | |
| color: #fefefe; | |
| tab-size: 2; | |
| border: none; | |
| resize: none; | |
| } | |
| .editor:focus { | |
| outline: none; | |
| } | |
| #error { | |
| grid-row: 2; | |
| grid-column: 1; | |
| margin: 0; | |
| padding-block: 0; | |
| padding-top: .5em; | |
| color: firebrick; | |
| overflow: auto; | |
| text-wrap: pretty; | |
| } | |
| #indicator { | |
| visibility: hidden; | |
| position: absolute; | |
| top: calc(var(--top, 0px) - var(--scroll-top, 0px)); | |
| width: 0; | |
| height: 0; | |
| border-top: 10px solid transparent; | |
| border-bottom: 10px solid transparent; | |
| border-left: 10px solid firebrick; | |
| transform: translateY(-25%); | |
| } | |
| .overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| margin: 0; | |
| } | |
| .editor, | |
| .overlay { | |
| font-size: 1rem; | |
| line-height: 1.2; | |
| white-space: pre; | |
| } | |
| #controls { | |
| position: fixed; | |
| top: 1em; | |
| right: 2em; | |
| } | |
| .controls { | |
| position: relative; | |
| display: flex; | |
| gap: 1.5em; | |
| padding: .5em 1.25em; | |
| background: #1111; | |
| border-radius: 4px; | |
| } | |
| .controls::before, | |
| .controls::after { | |
| content: ''; | |
| position: absolute; | |
| z-index: -1; | |
| inset: 0; | |
| transform: scale(.95); | |
| border-radius: inherit; | |
| opacity: 0; | |
| } | |
| .controls::before { | |
| background: #aef; | |
| animation: pulse 2s infinite; | |
| } | |
| .controls::after { | |
| background: #fefefe66; | |
| transition: transform 200ms ease-in-out; | |
| } | |
| .controls:hover::before, | |
| .controls:hover::after { | |
| opacity: 1; | |
| } | |
| .controls:hover::before { | |
| transform: scale(.98); | |
| filter: blur(2px); | |
| } | |
| .controls:hover::after { | |
| transform: scale(1.025, 1.1); | |
| } | |
| .controls:hover { | |
| background: #111f; | |
| } | |
| @keyframes pulse { | |
| 0% { | |
| transform: scale(1); | |
| } | |
| 50% { | |
| transform: scale(1.0125); | |
| } | |
| 100% { | |
| transform: scale(1); | |
| } | |
| } | |
| .hidden { | |
| display: none !important; | |
| } | |
| .opaque { | |
| opacity: 1 !important; | |
| background: #111 !important; | |
| } | |
| input { | |
| all: unset; | |
| opacity: .2; | |
| filter: saturate(0) invert(1); | |
| cursor: pointer; | |
| transition: opacity 200ms ease-in-out; | |
| padding: .25em .5em; | |
| } | |
| input:hover { | |
| opacity: 1; | |
| } | |
| .icon { | |
| text-align: center; | |
| line-height: 1; | |
| } | |
| #btnToggleView { | |
| width: 1.25em; | |
| } | |
| #btnToggleView::after { | |
| content: '👁'; | |
| } | |
| #btnToggleView:checked::after { | |
| content: '✏️'; | |
| } | |
| #btnToggleResolution::after { | |
| content: '1️⃣'; | |
| } | |
| #btnToggleResolution:checked::after { | |
| content: '2️⃣'; | |
| } | |
| #btnReset::after { | |
| content: '⏮️'; | |
| } |