๐Ÿ๐Ÿ๐Ÿ

readme header, many webui updates

autumn 3b91eef0 ac5a3e4e

+601 -207
+4 -2
readme.md
··· 1 - A python interactive environment, mainly for making various visualizations and simulations using pytorch. 1 + # ๐Ÿ ๐Ÿ ๐Ÿ 2 + 3 + A python interactive environment, mainly for making various visualizations and simulations using pytorch. also there's a static web ui framework thing 2 4 3 5 ![examples](examples/collage.png) 4 6 ··· 12 14 - Strange attractors and other ODEs 13 15 - & Many other dynamical systems 14 16 15 - An assortment of renders can be found in the examples folder. 17 + An assortment of old renders can be found in the examples folder. 16 18 17 19
+20
webui/content/butterfly.html
··· 1 + 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <meta http-equiv="X-UA-Compatible" content="ie=edge"> 8 + <title>WebUI</title> 9 + <link rel="icon" href="favicon.png"> 10 + <link id="themeLink" rel="stylesheet" type="text/css" href="style/theme/themes.css"> 11 + <link rel="stylesheet" type="text/css" href="style/main.css"> 12 + </head> 13 + <body> 14 + <script src="main.js"></script> 15 + <script> 16 + $mod("code/orb", document.body, ["butterfly.orb"]); 17 + </script> 18 + </body> 19 + </html> 20 +
+5
webui/content/orb/butterfly.orb
··· 1 + 2 + div[style=width:60%;margin:auto;margin-top:10rem;height:100%;] { 3 + $bsky/profile { } 4 + } 5 +
+223
webui/js/bsky/profile.js
··· 1 + 2 + $css(` 3 + .bsky { 4 + line-height: 1.5em; 5 + } 6 + 7 + .bsky .profile .title { 8 + line-height: 1.25em; 9 + text-align: center; 10 + margin: auto; 11 + width: fit-content; 12 + border-bottom: 3px double var(--main-faded); 13 + padding-left: 0.75em; 14 + padding-right: 0.75em; 15 + margin-top: 0.3em; 16 + } 17 + 18 + .bsky .profile .handle { 19 + font-size: 0.8em; 20 + text-align: center; 21 + color: var(--main-faded); 22 + } 23 + 24 + .bsky .profile .avatar { 25 + height: 6em; 26 + border-radius: 3px; 27 + flex-shrink: 0; 28 + border: 1px solid var(--main-solid); 29 + } 30 + 31 + .bsky .profile .id-header { 32 + display: flex; 33 + padding-bottom: 1em; 34 + } 35 + 36 + .bsky .profile .names { 37 + padding-top: 0em; 38 + width: 100%; 39 + } 40 + 41 + .bsky .quoted { 42 + position: relative; 43 + overflow: visible; 44 + margin-bottom: 1em; 45 + max-width: calc(100% - 2em); 46 + } 47 + 48 + .bsky .quoted::before { 49 + content: 'โ€œ'; 50 + padding-right: 0.5em; 51 + font-size: 2em; 52 + font-family: "Garamond", "Times New Roman", "Georgia", serif; 53 + } 54 + 55 + .bsky .quoted::after { 56 + content: 'โ€'; 57 + padding-left: 0.5em; 58 + font-size: 2em; 59 + position: absolute; 60 + bottom: 0px; 61 + font-family: "Garamond", "Times New Roman", "Georgia", serif; 62 + } 63 + 64 + .bsky .publications .banner { 65 + max-height: 15em; 66 + border-radius: 3px; 67 + width: 100%; 68 + } 69 + 70 + .bsky .publications .thumbnail { 71 + border-radius: 3px; 72 + max-width: 80%; 73 + margin: auto; 74 + } 75 + 76 + .bsky a.external { 77 + color: var(--main-solid); 78 + } 79 + 80 + .bsky .pin-container { 81 + display: inline-flex; 82 + flex-direction: column; 83 + max-width: calc(100% - 2em); 84 + } 85 + . 86 + `); 87 + 88 + async function getPublicData(endpoint, params = {}) { 89 + if (!endpoint) throw new Error("endpoint required"); 90 + 91 + const url = new URL(`https://public.api.bsky.app/xrpc/${endpoint}`); 92 + Object.entries(params).forEach(([key, value]) => { 93 + if (value !== undefined) url.searchParams.set(key, value); 94 + }); 95 + 96 + const response = await fetch(url); 97 + if (!response.ok) { 98 + const error = await response.json().catch(() => ({})); 99 + throw new Error(`Request failed: ${error.message || response.statusText}`); 100 + } 101 + 102 + return response.json(); 103 + } 104 + 105 + const profile = await getPublicData("app.bsky.actor.getProfile", { 106 + actor: "ponder.ooo" 107 + }); 108 + 109 + const pinned = (await getPublicData("app.bsky.feed.getPosts", { 110 + uris: [profile.pinnedPost.uri] 111 + })).posts[0]; 112 + 113 + console.log(pinned); 114 + 115 + export async function main(target) { 116 + const container = $div("full bsky"); 117 + 118 + const profileContainer = $div("full profile"); 119 + 120 + const publications = $div("full publications"); 121 + 122 + await $mod("layout/split", container, [{ content: [profileContainer, publications], percents: [40, 60]}]); 123 + 124 + const idHeader = $div("id-header"); 125 + 126 + const names = $div("names"); 127 + 128 + const handle = $div("handle"); 129 + handle.innerText = `@${profile.handle}`; 130 + 131 + const title = $element("h1"); 132 + title.innerText = profile.displayName; 133 + title.classList = "title"; 134 + 135 + const description = $element("p"); 136 + description.innerText = profile.description; 137 + description.classList = "quoted"; 138 + 139 + const avatar = $element("img"); 140 + avatar.src = profile.avatar; 141 + avatar.classList = "avatar"; 142 + 143 + const readers = $div(); 144 + readers.innerText = `${profile.followersCount} readers`; 145 + 146 + const pubCount = $div(); 147 + pubCount.innerText = `${profile.postsCount} publications`; 148 + 149 + const banner = $element("img"); 150 + banner.src = profile.banner; 151 + banner.classList = "banner"; 152 + publications.appendChild(banner); 153 + 154 + if (pinned.embed?.$type === "app.bsky.embed.images#view") { 155 + const pin = $div("quoted"); 156 + 157 + const pinContainer = $div("pin-container"); 158 + 159 + const pinText = $element("p"); 160 + pinText.innerText = pinned.record.text; 161 + pinContainer.appendChild(pinText); 162 + 163 + for (const imageData of pinned.embed.images) { 164 + const image = $element("img"); 165 + image.src = imageData.thumb; 166 + image.classList = "thumbnail"; 167 + pinContainer.appendChild(image); 168 + } 169 + 170 + publications.$with(pin.$with(pinContainer)); 171 + } 172 + else if (pinned.embed?.$type === "app.bsky.embed.external#view") { 173 + const pin = $div("quoted"); 174 + 175 + const pinContainer = $div("pin-container"); 176 + 177 + const pinText = $element("p"); 178 + pinText.innerText = pinned.record.text; 179 + pinContainer.appendChild(pinText); 180 + 181 + const embedTitle = $element("a"); 182 + embedTitle.href = pinned.embed.external.uri; 183 + embedTitle.innerText = pinned.embed.external.title; 184 + embedTitle.classList = "external"; 185 + 186 + const image = $element("img"); 187 + image.src = pinned.embed.external.thumb; 188 + image.classList = "thumbnail"; 189 + 190 + pinContainer.appendChild(embedTitle); 191 + pinContainer.appendChild(image); 192 + 193 + pin.appendChild(pinContainer); 194 + publications.appendChild(pin); 195 + 196 + } 197 + else { 198 + const pin = $element("p"); 199 + pin.innerText = pinned.record.text; 200 + pin.classList = "quoted"; 201 + publications.appendChild(pin); 202 + } 203 + 204 + profileContainer.$with( 205 + idHeader.$with( 206 + avatar, 207 + names.$with( 208 + title, 209 + handle 210 + ) 211 + ), 212 + description, 213 + pubCount, 214 + readers 215 + ); 216 + 217 + target.$with(container); 218 + 219 + return { replace: true }; 220 + } 221 + 222 + console.log(profile); 223 +
+2
webui/js/code/highlight.js
··· 253 253 output.scrollLeft = editor.scrollLeft; 254 254 }); 255 255 256 + editor.$ = { focusable: true, collapsible: false }; 257 + 256 258 // Initial highlight 257 259 updateHighlight(); 258 260
+130 -112
webui/js/control/menu.js
··· 66 66 } 67 67 `); 68 68 69 - export function main(target, ...args) { 70 - let items; 71 - if (args.length > 0 && args[0]) { 72 - items = args[0]; 69 + function collectItems(element) { 70 + const items = []; 71 + 72 + for (let node = element; node; node = node.parentNode) { 73 + if (node.$contextMenu !== null && node.$contextMenu !== undefined) { 74 + const nodeItems = $actualize(node.$contextMenu.items); 75 + for (const item of nodeItems.map($actualize)) { 76 + if (Array.isArray(item) && item[0] && Array.isArray(item[0])) { 77 + items.push(...item); 78 + } 79 + else { 80 + items.push(item); 81 + } 82 + } 83 + 84 + if (node.$contextMenu.override) { 85 + break; 86 + } 87 + } 73 88 } 74 89 75 - const backdrop = document.createElement("div"); 76 - backdrop.className = "context-backdrop"; 90 + return items; 91 + } 92 + 93 + 94 + const backdrop = document.createElement("div"); 95 + backdrop.className = "context-backdrop"; 96 + 97 + const menu = document.createElement("div"); 98 + menu.$ = {}; 99 + menu.className = "context-menu"; 100 + menu.setAttribute("role", "menu"); 101 + menu.setAttribute("aria-orientation", "vertical"); 102 + 103 + menu.addEventListener("mouseenter", () => { 104 + //menu.firstChild?.blur(); 105 + menu.focus(); 106 + }); 107 + 108 + const onBackdropClick = (e) => { 109 + if (e.target !== backdrop) return; 110 + e.preventDefault(); 77 111 78 - const menu = document.createElement("div"); 79 - menu.$ = {}; 80 - menu.className = "context-menu"; 81 - menu.setAttribute("role", "menu"); 82 - menu.setAttribute("aria-orientation", "vertical"); 112 + backdrop.style.display = "none"; 113 + menu.$.previousFocus?.focus(); 114 + 115 + // don't make user click twice when clicking away from the context menu 116 + const clickTarget = document.elementFromPoint(e.clientX, e.clientY); 117 + if (clickTarget) { 118 + clickTarget.focus(); 119 + clickTarget.dispatchEvent(new MouseEvent(e.type, { 120 + bubbles: true, 121 + cancelable: true, 122 + clientX: e.clientX, 123 + clientY: e.clientY 124 + })); 125 + } 126 + }; 83 127 84 - menu.addEventListener("mouseenter", () => { 85 - //menu.firstChild?.blur(); 86 - menu.focus(); 87 - }); 128 + backdrop.addEventListener("click", onBackdropClick); 129 + backdrop.addEventListener("contextmenu", onBackdropClick); 88 130 89 - const menuItems = Array.isArray(items) ? items : Object.entries(items); 131 + menu.addEventListener("keydown", (e) => { 132 + if (!["ArrowDown", "ArrowUp", "j", "k", "Escape"].includes(e.key)) return; 90 133 91 - const onBackdropClick = (e) => { 92 - if (e.target !== backdrop) return; 93 - e.preventDefault(); 134 + e.preventDefault(); 135 + e.stopPropagation(); 94 136 137 + if (e.key === "Escape") { 95 138 backdrop.style.display = "none"; 96 139 menu.$.previousFocus?.focus(); 140 + return; 141 + } 97 142 98 - // don't make user click twice when clicking away from the context menu 99 - const clickTarget = document.elementFromPoint(e.clientX, e.clientY); 100 - if (clickTarget) { 101 - clickTarget.focus(); 102 - clickTarget.dispatchEvent(new MouseEvent(e.type, { 103 - bubbles: true, 104 - cancelable: true, 105 - clientX: e.clientX, 106 - clientY: e.clientY 107 - })); 108 - } 109 - }; 143 + const currentItem = document.activeElement; 144 + if (!menu.contains(currentItem)) { 145 + menu.firstElementChild?.focus(); 146 + return; 147 + } 110 148 111 - backdrop.addEventListener("click", onBackdropClick); 112 - backdrop.addEventListener("contextmenu", onBackdropClick); 149 + let nextItem; 150 + if (e.key === "ArrowDown" || e.key === "j") { 151 + nextItem = currentItem.nextElementSibling || menu.firstElementChild; 152 + } else { 153 + nextItem = currentItem.previousElementSibling || menu.lastElementChild; 154 + } 113 155 114 - menuItems.forEach(item => { 115 - if (item === null) { 156 + nextItem.focus(); 157 + }); 158 + 159 + backdrop.appendChild(menu); 160 + 161 + const showMenu = (target, position = null) => { 162 + document.body.appendChild(backdrop); 163 + backdrop.style.display = "block"; 164 + menu.$.previousFocus = document.activeElement; 165 + menu.firstChild?.focus(); 166 + 167 + const bounds = target.getBoundingClientRect(); 168 + 169 + if (!position) { 170 + menu.setAttribute("centered", ""); 171 + menu.style.left = ""; 172 + menu.style.top = ""; 173 + return; 174 + } 175 + 176 + const {x,y} = position; 177 + 178 + menu.removeAttribute("centered"); 179 + menu.style.left = x + "px"; 180 + menu.style.top = y + "px"; 181 + 182 + const rect = menu.getBoundingClientRect(); 183 + 184 + if (rect.right > bounds.right) { 185 + menu.style.left = (x - rect.width) + "px"; 186 + } 187 + if (rect.left < bounds.left) { 188 + menu.style.left = bounds.left + "px"; 189 + } 190 + if (rect.bottom > bounds.bottom) { 191 + menu.style.top = (y - rect.height) + "px"; 192 + } 193 + if (rect.top < bounds.top) { 194 + menu.style.top = bounds.top + "px"; 195 + } 196 + }; 197 + 198 + document.addEventListener("contextmenu", (e) => { 199 + menu.replaceChildren(); 200 + 201 + const items = collectItems(e.target); 202 + 203 + if (items.length === 0) return; 204 + 205 + items.forEach(item => { 206 + if (!item) return; 207 + if (item === "separator") { // TODO improve this 116 208 const separator = document.createElement("div"); 117 209 separator.className = "context-menu-separator"; 118 210 menu.appendChild(separator); ··· 143 235 menu.appendChild(menuItem); 144 236 }); 145 237 146 - menu.addEventListener("keydown", (e) => { 147 - if (!["ArrowDown", "ArrowUp", "j", "k", "Escape"].includes(e.key)) return; 148 - 149 - e.preventDefault(); 150 - 151 - if (e.key === "Escape") { 152 - backdrop.style.display = "none"; 153 - menu.$.previousFocus?.focus(); 154 - return; 155 - } 156 - 157 - const currentItem = document.activeElement; 158 - if (!menu.contains(currentItem)) { 159 - menu.firstElementChild?.focus(); 160 - return; 161 - } 238 + e.preventDefault(); 162 239 163 - let nextItem; 164 - if (e.key === "ArrowDown" || e.key === "j") { 165 - nextItem = currentItem.nextElementSibling || menu.firstElementChild; 166 - } else { 167 - nextItem = currentItem.previousElementSibling || menu.lastElementChild; 168 - } 169 - 170 - nextItem.focus(); 171 - }); 172 - 173 - backdrop.appendChild(menu); 174 - target.appendChild(backdrop); 175 - 176 - const showMenu = (position = null) => { 177 - backdrop.style.display = "block"; 178 - menu.$.previousFocus = document.activeElement; 179 - menu.firstChild?.focus(); 180 - 181 - const bounds = target.getBoundingClientRect(); 182 - 183 - if (!position) { 184 - menu.setAttribute("centered", ""); 185 - menu.style.left = ""; 186 - menu.style.top = ""; 187 - return; 188 - } 189 - 190 - const {x,y} = position; 191 - 192 - menu.removeAttribute("centered"); 193 - menu.style.left = x + "px"; 194 - menu.style.top = y + "px"; 195 - 196 - const rect = menu.getBoundingClientRect(); 197 - 198 - if (rect.right > bounds.right) { 199 - menu.style.left = (x - rect.width) + "px"; 200 - } 201 - if (rect.left < bounds.left) { 202 - menu.style.left = bounds.left + "px"; 203 - } 204 - if (rect.bottom > bounds.bottom) { 205 - menu.style.top = (y - rect.height) + "px"; 206 - } 207 - if (rect.top < bounds.top) { 208 - menu.style.top = bounds.top + "px"; 209 - } 210 - }; 211 - 212 - target.addEventListener("contextmenu", (e) => { 213 - if (e.target !== e.currentTarget) return; 214 - e.preventDefault(); 215 - 216 - showMenu({x: e.clientX, y: e.clientY}); 217 - }); 218 - 219 - return { 220 - replace: false, 221 - showMenu 222 - }; 223 - } 240 + showMenu(e.target, {x: e.clientX, y: e.clientY}); 241 + }); 224 242
+69 -46
webui/js/gpu/proj_shift.js
··· 5 5 flex-direction: row; 6 6 } 7 7 8 - .proj-shift-container .overlay { 8 + .overlay { 9 9 user-select: none; 10 10 position: absolute; 11 + z-index: 1; 11 12 top: 0; 12 13 left: 0; 13 14 pointer-events: none; ··· 29 30 let centerY = 0.0; 30 31 let zoom = 4.0; 31 32 32 - function complexMag(z) { 33 - return Math.sqrt(z.x * z.x + z.y * z.y); 34 - } 33 + let showTrajectory = false; 35 34 36 - function complexAngle(z) { 37 - return Math.atan2(z.y, z.x); 38 - } 35 + function complexMag(z) { 36 + return Math.sqrt(z.x * z.x + z.y * z.y); 37 + } 39 38 40 - function projectiveShift(x, phi, psi) { 41 - const xMag = complexMag(x); 42 - const xAngle = complexAngle(x); 43 - const angleDiff = xAngle - phi; 44 - const newMag = xMag + Math.cos(angleDiff); 45 - return { 46 - x: newMag * Math.cos(xAngle * psi), 47 - y: newMag * Math.sin(xAngle * psi) 48 - }; 49 - } 39 + function complexAngle(z) { 40 + return Math.atan2(z.y, z.x); 41 + } 50 42 51 - function iteratePolar(x, phi, psi, c) { 52 - const shifted = projectiveShift(x, phi, psi); 53 - return { x: shifted.x - c, y: shifted.y }; 54 - } 43 + function projectiveShift(x, phi, psi) { 44 + const xMag = complexMag(x); 45 + const xAngle = complexAngle(x); 46 + const angleDiff = xAngle - phi; 47 + const newMag = xMag + Math.cos(angleDiff); 48 + return { 49 + x: newMag * Math.cos(xAngle * psi), 50 + y: newMag * Math.sin(xAngle * psi) 51 + }; 52 + } 53 + 54 + function iteratePolar(x, phi, psi, c) { 55 + const shifted = projectiveShift(x, phi, psi); 56 + return { x: shifted.x - c, y: shifted.y }; 57 + } 55 58 56 - function computeTrajectory(startZ, maxIters, escapeThreshold) { 57 - const trajectory = [startZ]; 58 - let z = { x: startZ.x, y: startZ.y }; 59 + function computeTrajectory(startZ, maxIters, escapeThreshold) { 60 + const trajectory = [startZ]; 61 + let z = { x: startZ.x, y: startZ.y }; 59 62 60 - for (let i = 0; i < maxIters; i++) { 61 - const magSq = z.x * z.x + z.y * z.y; 62 - if (magSq > escapeThreshold * escapeThreshold) { 63 - break; 63 + for (let i = 0; i < maxIters; i++) { 64 + const magSq = z.x * z.x + z.y * z.y; 65 + if (magSq > escapeThreshold * escapeThreshold) { 66 + break; 67 + } 68 + z = iteratePolar(z, phi, psi, c); 69 + trajectory.push({ x: z.x, y: z.y }); 64 70 } 65 - z = iteratePolar(z, phi, psi, c); 66 - trajectory.push({ x: z.x, y: z.y }); 71 + 72 + return trajectory; 67 73 } 68 74 69 - return trajectory; 70 - } 71 - 72 - function complexToPixel(z) { 73 - const aspect = width / height; 74 - const scale = 4.0 / zoom; 75 - const px = (z.x - centerX) * width / (scale * aspect) + width * 0.5; 76 - const py = (z.y - centerY) * height / scale + height * 0.5; 77 - return { x: px, y: py }; 78 - } 75 + function complexToPixel(z) { 76 + const aspect = width / height; 77 + const scale = 4.0 / zoom; 78 + const px = (z.x - centerX) * width / (scale * aspect) + width * 0.5; 79 + const py = (z.y - centerY) * height / scale + height * 0.5; 80 + return { x: px, y: py }; 81 + } 79 82 80 83 81 84 ··· 147 150 }, 148 151 ]]); 149 152 150 - const renderStack = document.createElement("div"); 151 - renderStack.classList = "full"; 153 + const renderStack = $div("full"); 152 154 renderStack.style.position = "relative"; 153 155 154 156 const gpuModule = await $mod("gpu/webgpu", renderStack); ··· 189 191 } 190 192 }); 191 193 192 - const overlay = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 194 + const overlay = $svgElement("svg"); 193 195 overlay.classList = "full overlay"; 194 196 195 197 overlay.setAttribute("aria-label", "Overlay visualizing the trajectory starting from the point under the cursor.") 196 198 197 - const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle"); 199 + const dot = $svgElement("circle"); 198 200 dot.setAttribute("r", "3") 199 201 dot.setAttribute("fill", "red"); 200 202 dot.style.display = "block"; 201 203 202 204 overlay.appendChild(dot); 203 205 renderStack.appendChild(overlay); 206 + 207 + function showControls() { 208 + if (controls.parentNode) return; 209 + 210 + return ["show controls", async () => { 211 + await $mod("layout/split", renderStack.parentNode, [{content: [controls, renderStack], percents: [20, 80]}]); 212 + }]; 213 + } 214 + 215 + function toggleTrajectory() { 216 + if (showTrajectory) return ["hide trajectory", () => {showTrajectory = false}]; 217 + return ["show trajectory", () => {showTrajectory = true}]; 218 + } 219 + 220 + renderStack.$preventCollapse = true; 221 + renderStack.$contextMenu = { 222 + items: [showControls, toggleTrajectory] 223 + }; 204 224 205 225 await $mod("layout/split", target, [{ content: [controls, renderStack], percents: [20, 80]}]); 206 226 ··· 366 386 let lastMouseY = 0; 367 387 368 388 canvas.addEventListener("pointerdown", (e) => { 389 + if (e.button !== 0) return; 369 390 isDragging = true; 370 391 const rect = canvas.getBoundingClientRect(); 371 392 lastMouseX = e.clientX - rect.left; ··· 399 420 existingPath.remove(); 400 421 } 401 422 402 - if (false && trajectory.length > 1) { 403 - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); 423 + if (showTrajectory && trajectory.length > 1) { 424 + const path = $svgElement("path"); 404 425 path.setAttribute("class", "trajectory-path"); 405 426 path.setAttribute("fill", "none"); 406 427 path.setAttribute("stroke", "lime"); ··· 455 476 456 477 // Mouse up - stop dragging 457 478 canvas.addEventListener("pointerup", () => { 479 + if (!isDragging) return; 458 480 isDragging = false; 459 481 canvas.style.cursor = "crosshair"; 460 482 }); 461 483 462 484 // Mouse leave - stop dragging if mouse leaves canvas 463 485 canvas.addEventListener("pointerleave", () => { 486 + if (!isDragging) return; 464 487 isDragging = false; 465 488 canvas.style.cursor = "crosshair"; 466 489 dot.style.display = "block";
+8 -5
webui/js/layout/nothing.js
··· 28 28 `); 29 29 30 30 export async function main(target) { 31 - const backdrop = document.createElement("div"); 31 + const backdrop = $div("nothing"); 32 32 33 - backdrop.className = "nothing"; 34 33 backdrop.$ = { 35 - focusable: true 34 + focusable: true, 36 35 }; 37 36 38 37 backdrop.tabIndex = 0; ··· 41 40 42 41 const load = (modName, args=[]) => { 43 42 return async () => { 44 - const result = await $mod(modName, target, args); 43 + const result = await $mod(modName, backdrop.parentNode, args); 45 44 if (result?.replace) { 46 45 backdrop.remove(); 47 46 } ··· 63 62 whiteboard: load("theme", ["whiteboard"]), 64 63 spinner: load("spinner"), 65 64 highlight: load("code/highlight") 66 - } 65 + }; 66 + 67 + backdrop.$contextMenu = { 68 + items: Object.entries(menuItems) 69 + }; 67 70 68 71 const menu = await $mod("control/menu", backdrop, [menuItems]); 69 72
+92 -11
webui/js/layout/split.js
··· 123 123 return NodeFilter.FILTER_SKIP; 124 124 }); 125 125 if (reverse) { 126 - while (walker.nextNode()) { } 127 - walker.previousNode(); 126 + let lastNode = null; 127 + while (walker.nextNode()) { 128 + lastNode = walker.currentNode; 129 + } 130 + return lastNode; 128 131 } 129 132 return walker.nextNode(); 130 133 } ··· 142 145 143 146 var n = content.length; 144 147 145 - const container = document.createElement("div"); 146 - container.className = "split"; 148 + const container = $div("split"); 147 149 container.setAttribute("orientation", settings.orientation); 148 150 var row = settings.orientation === "row"; 149 151 152 + const orientationToggle = [row ? "row->col" : "col->row", () => { 153 + row = !row; 154 + settings.orientation = row ? "row" : "col"; 155 + container.setAttribute("orientation", settings.orientation); 156 + orientationToggle[0] = row ? "row->col" : "col->row"; 157 + }]; 158 + 159 + 150 160 container.addEventListener("keydown", (e) => { 151 161 if (e.target.matches("input, textarea, [contenteditable=\"true\"]")) return; 152 162 ··· 158 168 const prevIndex = currentIndex - 1; 159 169 const prev = focusableDescendent(targets[prevIndex], true); 160 170 if (prev) prev.focus(); 171 + e.stopPropagation(); 161 172 } 162 173 else if (e.key === (row ? "l" : "j")) { 163 174 const currentIndex = targets.findIndex(t => t.contains(document.activeElement)); ··· 167 178 const nextIndex = currentIndex + 1; 168 179 const next = focusableDescendent(targets[nextIndex]); 169 180 if (next) next.focus(); 181 + 182 + e.stopPropagation(); 170 183 } 171 184 172 - e.stopPropagation(); 173 185 }); 174 186 175 187 const portions = []; ··· 227 239 228 240 const targets = []; 229 241 242 + function collapse(removedIndex, keptIndex) { 243 + n = n - 1; 244 + 245 + if (n === 1) { 246 + container.parentNode.replaceChildren(...targets[keptIndex].childNodes); 247 + return; 248 + } 249 + 250 + portions[removedIndex].remove(); 251 + const removedExtent = parseFloat(portions[removedIndex].style.getPropertyValue("--current-portion")); 252 + const keptExtent = parseFloat(portions[keptIndex].style.getPropertyValue("--current-portion")); 253 + portions[keptIndex].style.setProperty("--current-portion", `${removedExtent + keptExtent}%`); 254 + 255 + for (let i = removedIndex + 1; i < n; i++) { 256 + container.insertBefore(portions[i], splitters[i-1]); 257 + } 258 + 259 + portions.splice(removedIndex, 1); 260 + targets.splice(removedIndex, 1); 261 + splitters[n - 1].remove(); 262 + splitters.splice(n - 1, 1); 263 + } 264 + 265 + function tryCollapse(separatorIndex) { 266 + const prior = targets[separatorIndex]; 267 + const posterior = targets[separatorIndex + 1]; 268 + 269 + const priorCollapse = ![...prior.childNodes].some(child => $actualize(child.$preventCollapse)); 270 + const posteriorCollapse = ![...posterior.childNodes].some(child => $actualize(child.$preventCollapse)); 271 + 272 + if (!(priorCollapse || posteriorCollapse)) return; 273 + 274 + collapse(priorCollapse ? separatorIndex : separatorIndex + 1, priorCollapse ? separatorIndex + 1 : separatorIndex); 275 + } 276 + 277 + function collapseOptions(separatorIndex) { 278 + return () => { 279 + const prior = targets[separatorIndex]; 280 + const posterior = targets[separatorIndex + 1]; 281 + 282 + const priorCollapse = ![...prior.childNodes].some(child => $actualize(child.$preventCollapse)); 283 + const posteriorCollapse = ![...posterior.childNodes].some(child => $actualize(child.$preventCollapse)); 284 + 285 + if (!(priorCollapse || posteriorCollapse)) return; 286 + 287 + if (priorCollapse && posteriorCollapse) { 288 + return [ 289 + [`collapse ${row ? "left" : "top"}`, () => collapse(separatorIndex, separatorIndex + 1)], 290 + [`collapse ${row ? "right" : "bottom"}`, () => collapse(separatorIndex + 1, separatorIndex)] 291 + ]; 292 + } 293 + 294 + return ["collapse", () => collapse(priorCollapse ? separatorIndex : separatorIndex + 1, priorCollapse ? separatorIndex + 1 : separatorIndex)]; 295 + } 296 + } 297 + 230 298 for (let i = 0; i < n; i++) { 231 - const portion = document.createElement("div"); 232 - portion.className = "portion"; 299 + const portion = $div("portion"); 233 300 if (settings.percents === "equal") { 234 301 portion.style.setProperty("--current-portion", `${100/n}%`); 235 302 } else { 236 303 portion.style.setProperty("--current-portion", `${settings.percents[i]}%`); 237 304 } 238 305 239 - const target = document.createElement("div"); 240 - target.className = "target"; 306 + const target = $div("target"); 241 307 242 308 targets.push(target); 243 309 portions.push(portion); 244 - portion.appendChild(target); 245 - container.appendChild(portion); 310 + 311 + container.$with(portion.$with(target)); 246 312 247 313 if (i === n - 1) continue; 248 314 249 315 const splitter = document.createElement("div"); 250 316 splitter.className = "splitter"; 317 + 318 + splitter.$contextMenu = { 319 + items: [orientationToggle, collapseOptions(i)] 320 + }; 321 + 322 + splitter.addEventListener("pointerdown", (e) => { 323 + if (e.button !== 1) return; 324 + 325 + tryCollapse(i); 326 + }); 327 + 251 328 splitters.push(splitter); 252 329 container.appendChild(splitter); 253 330 ··· 264 341 targets[i].appendChild(content[i]); 265 342 } 266 343 } 344 + 345 + container.$preventCollapse = () => { 346 + return targets.some(target => [...target.childNodes].some(child => $actualize(child.$preventCollapse))); 347 + }; 267 348 268 349 return { 269 350 replace: true
+27
webui/js/main.js
··· 59 59 document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; 60 60 }; 61 61 62 + Object.defineProperty(Element.prototype, "$with", { 63 + value: function(...children) { 64 + for (const child of children) { 65 + this.appendChild(child); 66 + } 67 + return this; 68 + }, 69 + enumerable: false 70 + }); 71 + 72 + window.$element = (name) => document.createElement(name); 73 + window.$div = function (classList = "") { 74 + const div = $element("div"); 75 + div.classList = classList; 76 + return div; 77 + } 78 + 79 + window.$svgElement = (name) => document.createElementNS("http://www.w3.org/2000/svg", name); 80 + window.$mathElement = (name) => document.createElementNS("http://www.w3.org/1998/Math/MathML", name); 81 + 62 82 window.$tau = 6.283185307179586; 83 + 84 + window.$actualize = (maybeFunction) => { 85 + if (typeof maybeFunction === "function") return maybeFunction(); 86 + return maybeFunction; 87 + }; 88 + 89 + import('/control/menu.js'); 63 90 64 91 $mod("theme", document.body); 65 92
+2 -4
webui/js/math/math.js
··· 1 - 2 - function mathElement(tag) { return document.createElementNS("http://www.w3.org/1998/Math/MathML", tag); } 3 1 4 2 function makeLeaf(tag, content) { 5 3 return () => { 6 - const element = mathElement(tag); 4 + const element = $mathElement(tag); 7 5 element.textContent = content; 8 6 return element; 9 7 }; ··· 23 21 24 22 function makeGroup(tag) { 25 23 return (children) => { 26 - const element = mathElement(tag); 24 + const element = $mathElement(tag); 27 25 for (const child of children) { 28 26 element.appendChild(make(child)); 29 27 }
+5 -5
webui/js/prompt.js
··· 1 1 2 2 export async function main(target) { 3 - const container = document.createElement("div"); 3 + const container = $div(); 4 4 5 - const input = document.createElement("input"); 5 + const input = $element("input"); 6 6 input.type = "text"; 7 7 input.placeholder = "..."; 8 8 ··· 12 12 const moduleName = inputSplit[0]; 13 13 const args = inputSplit.slice(1); 14 14 15 - const result = await $mod(moduleName, target, args); 15 + const result = await $mod(moduleName, container.parentNode, args); 16 16 if (result?.replace) { 17 17 container.remove(); 18 18 } ··· 24 24 } 25 25 }); 26 26 27 - container.appendChild(input); 28 - target.appendChild(container); 27 + target.$with(container.$with(input)); 28 + 29 29 input.focus(); 30 30 31 31 return {
+14 -22
webui/js/spinner.js
··· 84 84 `); 85 85 86 86 export async function main(target, modNext = "layout/nothing") { 87 - let spinner = document.createElement("div"); 88 - let orb0 = document.createElement("div"); 89 - let orb1 = document.createElement("div"); 90 - let orb2 = document.createElement("div"); 91 - let button = document.createElement("button"); 87 + let spinner = $div("spinner"); 88 + let orb0 = $div("spinner-orb 0"); 89 + let orb1 = $div("spinner-orb 1"); 90 + let orb2 = $div("spinner-orb 2"); 91 + let button = $element("button"); 92 92 93 93 button.innerText = "enter"; 94 94 button.classList.add("spinner-button"); 95 95 96 - spinner.classList.add("spinner"); 97 - 98 - orb0.classList.add("spinner-orb"); 99 - orb0.classList.add("0"); 100 - 101 - orb1.classList.add("spinner-orb"); 102 - orb1.classList.add("1"); 103 - 104 - orb2.classList.add("spinner-orb"); 105 - orb2.classList.add("2"); 106 - 107 - target.appendChild(spinner); 108 - target.appendChild(button); 109 - spinner.appendChild(orb0); 110 - orb0.appendChild(orb1); 111 - orb1.appendChild(orb2); 96 + target.$with( 97 + spinner.$with( 98 + orb0.$with( 99 + orb1.$with(orb2) 100 + ) 101 + ), 102 + button 103 + ); 112 104 113 105 let removeLoader = async (e) => { 114 106 spinner.style.opacity = "0"; ··· 116 108 orb0.style.left = "50%"; 117 109 orb1.style.left = "50%"; 118 110 orb2.style.left = "50%"; 119 - await $mod(modNext, target); 111 + await $mod(modNext, spinner.parentNode); 120 112 setTimeout(async () => { 121 113 spinner.remove(); 122 114 button.remove();