From b701efd803183868ead129cdcd123d01814db1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Remko=20Tron=C3=A7on?= Date: Fri, 20 May 2022 20:30:56 +0200 Subject: [PATCH] thurtle: Share & Export --- build-web.js | 5 +- package.json | 2 + src/web/thurtle/jsx.ts | 1 + src/web/thurtle/programs.ts | 24 +-- src/web/thurtle/thurtle.css | 5 +- src/web/thurtle/thurtle.tsx | 311 ++++++++++++++++++++++++++++++++---- yarn.lock | 10 ++ 7 files changed, 313 insertions(+), 45 deletions(-) diff --git a/build-web.js b/build-web.js index 1cd7656..bfbd329 100755 --- a/build-web.js +++ b/build-web.js @@ -151,7 +151,8 @@ async function handleBuildFinished(result) { if (watch) { // Simple static file server createServer(async function (req, res) { - let f = path.join(__dirname, "public", req.url); + const url = req.url.replace(/\?.*/g, ""); + let f = path.join(__dirname, "public", url); try { if ((await fs.promises.lstat(f)).isDirectory()) { f = path.join(f, "index.html"); @@ -163,7 +164,7 @@ if (watch) { const data = await fs.promises.readFile(f); res.writeHead( 200, - req.url.endsWith(".svg") + url.endsWith(".svg") ? { "Content-Type": "image/svg+xml", } diff --git a/package.json b/package.json index 17f77d1..0ac9b9f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "repository": "github:remko/waforth", "dependencies": {}, "devDependencies": { + "@types/file-saver": "^2.0.5", "@types/node": "^17.0.31", "chai": "^4.3.6", "esbuild": "^0.14.36", @@ -12,6 +13,7 @@ "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.4.0", + "file-saver": "^2.0.5", "immutability-helper": "^3.1.1", "lodash": "^4.17.21", "mocha": "^9.2.2", diff --git a/src/web/thurtle/jsx.ts b/src/web/thurtle/jsx.ts index 326bee7..37b91f3 100644 --- a/src/web/thurtle/jsx.ts +++ b/src/web/thurtle/jsx.ts @@ -29,6 +29,7 @@ declare global { }; g: JSXElement> & { xmlns: "http://www.w3.org/2000/svg"; + stroke?: string; transform?: string; }; [elemName: string]: any; diff --git a/src/web/thurtle/programs.ts b/src/web/thurtle/programs.ts index 00559b5..37cee42 100644 --- a/src/web/thurtle/programs.ts +++ b/src/web/thurtle/programs.ts @@ -2,6 +2,7 @@ export type Program = { name: string; program: string; isExample: boolean; + isEphemeral?: boolean; }; const examples: Program[] = [ @@ -98,7 +99,6 @@ const examples: Program[] = [ 98 100 */ RECURSE ; -PENUP -500 -180 SETXY PENDOWN 140 SPIRAL `, }, @@ -152,11 +152,6 @@ PENUP -500 -180 SETXY PENDOWN ; : SNOWFLAKE ( -- ) - PENUP - LENGTH 4 / NEGATE - LENGTH 2/ NEGATE - SETXY - PENDOWN 3 0 DO LENGTH DEPTH SIDE 120 RIGHT @@ -173,6 +168,7 @@ SNOWFLAKE program: ` 450 CONSTANT SIZE 7 CONSTANT BRANCHES +160 CONSTANT SPREAD VARIABLE RND HERE RND ! @@ -192,14 +188,13 @@ HERE RND ! OVER FORWARD BRANCHES 0 DO OVER 2/ - 160 CHOOSE 80 - + SPREAD CHOOSE SPREAD 2/ - RECURSE LOOP - SWAP BACKWARD + PENUP SWAP BACKWARD PENDOWN LEFT ; -PENUP 0 SIZE NEGATE SETXY PENDOWN 1 SETPENSIZE SIZE 0 PLANT `, @@ -226,10 +221,11 @@ export function getProgram(name: string): Program | undefined { } function savePrograms() { + console.log("SavePrograms", programs); try { window.localStorage.setItem( "thurtle:programs", - JSON.stringify(programs.filter((p) => !p.isExample)) + JSON.stringify(programs.filter((p) => !p.isExample && !p.isEphemeral)) ); } catch (e) { console.error(e); @@ -237,17 +233,23 @@ function savePrograms() { } } -export function saveProgram(name: string, program: string): boolean { +export function saveProgram( + name: string, + program: string, + ephemeral = false +): boolean { const prg = getProgram(name); let isNew = false; if (prg != null) { prg.program = program; + prg.isEphemeral = ephemeral; } else { isNew = true; programs.push({ name, isExample: false, program, + isEphemeral: ephemeral, }); } savePrograms(); diff --git a/src/web/thurtle/thurtle.css b/src/web/thurtle/thurtle.css index c8847e9..f4f516e 100644 --- a/src/web/thurtle/thurtle.css +++ b/src/web/thurtle/thurtle.css @@ -31,7 +31,10 @@ .output { font-size: 0.8em; - overflow: scroll; + overflow: auto; + max-height: calc( + 100vh - 450px + ); /* hack because i can't figure out how to auto scale this */ } label { diff --git a/src/web/thurtle/thurtle.tsx b/src/web/thurtle/thurtle.tsx index 844522d..39a56fe 100644 --- a/src/web/thurtle/thurtle.tsx +++ b/src/web/thurtle/thurtle.tsx @@ -11,7 +11,9 @@ import { saveProgram, } from "./programs"; import Editor from "./Editor"; -import path from "path"; +import { saveAs } from "file-saver"; + +declare let bootstrap: any; function About() { return ( @@ -83,14 +85,35 @@ const rootEl = (
-
- -
-
+
+ +
+
+
{editor.el} -
- - - + id="paths" + > +
-

+              

             
@@ -260,6 +298,55 @@ const rootEl = (
+ + +
); document.body.appendChild(rootEl); @@ -279,6 +366,18 @@ const outputEl = rootEl.querySelector( const deleteActionEl = rootEl.querySelector( "[data-hook=delete-action]" ) as HTMLAnchorElement; +const shareModalEl = rootEl.querySelector("[data-hook=share-modal]"); +const shareModal = new bootstrap.Modal(shareModalEl); +const shareModalTitleEl = rootEl.querySelector( + "[data-hook=share-modal] [data-hook=title]" +) as HTMLSpanElement; +const savingModal = new bootstrap.Modal( + rootEl.querySelector("[data-hook=saving-modal]") +); +const shareModalURLEl = rootEl.querySelector( + "[data-hook=share-modal] [data-hook=url]" +) as HTMLInputElement; +const worldEl = rootEl.querySelector("[data-hook=world]") as SVGSVGElement; enum PenState { Up = 0, @@ -292,12 +391,14 @@ type Path = { let rotation = 0; let position = { x: 0, y: 0 }; +let boundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; let pen = PenState.Down; let visible = true; let paths: Array = [{ d: [`M${position.x} ${position.y}`] }]; function reset() { position.x = position.y = 0; + boundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; rotation = 270; pen = PenState.Down; paths = [{ d: [`M ${position.x} ${position.y}`] }]; @@ -317,14 +418,14 @@ function forward(d: number) { paths[paths.length - 1].d.push( [pen === PenState.Down ? "l" : "m", dx, dy].join(" ") ); + updatePosition(position.x + dx, position.y + dy); } function setXY(x: number, y: number) { paths[paths.length - 1].d.push( [pen === PenState.Down ? "l" : "M", x, y].join(" ") ); - position.x = x; - position.y = y; + updatePosition(x, y); } function setPen(s: PenState) { @@ -339,10 +440,29 @@ function setVisible(b: boolean) { visible = b; } +function updatePosition(x: number, y: number) { + position.x = x; + position.y = y; + if (x < boundingBox.minX) { + boundingBox.minX = x; + } + if (x > boundingBox.maxX) { + boundingBox.maxX = x; + } + if (y < boundingBox.minY) { + boundingBox.minY = y; + } + if (y > boundingBox.maxY) { + boundingBox.maxY = y; + } +} + ////////////////////////////////////////////////////////////////////////////////////////// // Drawing ////////////////////////////////////////////////////////////////////////////////////////// +const padding = 0.05; + function draw() { patshEl.innerHTML = ""; for (const path of paths) { @@ -363,6 +483,24 @@ function draw() { position.x - 25 } ${position.y - 25})` ); + + const width = boundingBox.maxX - boundingBox.minX; + const height = boundingBox.maxY - boundingBox.minY; + if (width == 0 || height == 0) { + worldEl.setAttribute("viewBox", "-500 -500 1000 1000"); + } else { + const paddingX = width * padding; + const paddingY = height * padding; + worldEl.setAttribute( + "viewBox", + [ + Math.floor(boundingBox.minX - paddingX), + Math.floor(boundingBox.minY - paddingY), + Math.ceil(width + 2 * paddingX), + Math.ceil(height + 2 * paddingY), + ].join(" ") + ); + } } ////////////////////////////////////////////////////////////////////////////////////////// @@ -427,10 +565,86 @@ function del(ev: MouseEvent) { loadProgram(DEFAULT_PROGRAM); } +async function getSVG(): Promise<{ + data: string; + width: number; + height: number; +}> { + await run(); + const n = worldEl.cloneNode(true) as SVGSVGElement; + n.querySelector("[data-hook=turtle]")?.remove(); + const viewBox = n.getAttribute("viewBox")!.split(" "); + n.setAttribute("width", parseInt(viewBox[2]) + ""); + n.setAttribute("height", parseInt(viewBox[3]) + ""); + return { + width: parseInt(n.getAttribute("width")!), + height: parseInt(n.getAttribute("height")!), + data: n.outerHTML, + }; +} + +async function downloadSVG(ev: MouseEvent) { + ev.preventDefault(); + const blob = new Blob([(await getSVG()).data], { type: "image/svg+xml" }); + saveAs(blob, programsEl.value + ".svg"); +} + +async function downloadPNG(ev: MouseEvent) { + ev.preventDefault(); + savingModal.show(); + const svg = await getSVG(); + const img = document.createElement("img"); + img.style.display = "none"; + document.body.appendChild(img); + try { + const dataURL = await new Promise((resolve, reject) => { + img.onerror = (e) => { + reject(e); + }; + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = svg.width; + canvas.height = svg.height; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + resolve(canvas.toDataURL("image/png")); + }; + img.src = "data:image/svg+xml;base64," + btoa(svg.data); + }); + const blob = await (await fetch(dataURL)).blob(); + saveAs(blob, programsEl.value + ".png"); + } catch (e) { + console.error(e); + } finally { + img.remove(); + savingModal.hide(); + } +} + +function share(ev: MouseEvent) { + ev.preventDefault(); + shareModalTitleEl.innerText = programsEl.value; + shareModalURLEl.value = `${window.location.protocol}//${ + window.location.host + }${window.location.pathname}?pn=${encodeURIComponent( + programsEl.value + )}&p=${encodeURIComponent(btoa(editor.getValue()))}&ar=1`; + shareModal.show(); +} + +function selectShareURL() { + shareModalURLEl.focus(); + shareModalURLEl.setSelectionRange(0, 9999); + shareModalURLEl.scrollLeft = 0; +} + +shareModalEl.addEventListener("shown.bs.modal", () => { + selectShareURL(); +}); + programsEl.addEventListener("change", (ev) => { loadProgram((ev.target! as HTMLSelectElement).value); }); -loadPrograms(); ////////////////////////////////////////////////////////////////////////////////////////// @@ -465,7 +679,12 @@ async function run() { setRotation(-90 - stack.pop()); }); forth.interpret(thurtleFS); - forth.onEmit = (c) => outputEl.appendChild(document.createTextNode(c)); + forth.onEmit = (c) => { + outputEl.appendChild(document.createTextNode(c)); + if (c === "\n") { + outputEl.scrollTop = outputEl.scrollHeight; + } + }; forth.interpret(editor.getValue()); editor.focus(); @@ -477,14 +696,44 @@ async function run() { } } -runButtonEl.addEventListener("click", () => run()); document.addEventListener("keydown", (ev) => { if (ev.key == "Enter" && (ev.metaKey || ev.ctrlKey)) { run(); } }); -reset(); -draw(); +//////////////////////////////////////////////////////////////////// -loadProgram(DEFAULT_PROGRAM); +function parseQS(sqs = window.location.search) { + const qs: Record = {}; + const sqss = sqs + .substring(sqs.indexOf("?") + 1) + .replace(/\+/, " ") + .split("&"); + for (const p of sqss) { + const j = p.indexOf("="); + if (j > 0) { + qs[decodeURIComponent(p.substring(0, j))] = decodeURIComponent( + p.substring(j + 1) + ); + } + } + return qs; +} + +const qs = parseQS(); +if (qs.p) { + saveProgram(qs.pn ?? "", atob(qs.p), true); + loadPrograms(); + loadProgram(qs.pn); +} else { + loadPrograms(); + loadProgram(qs.pn ?? DEFAULT_PROGRAM); +} + +if (qs.ar) { + run(); +} else { + reset(); + draw(); +} diff --git a/yarn.lock b/yarn.lock index 1f766db..ca6e35e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31,6 +31,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@types/file-saver@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.5.tgz#9ee342a5d1314bb0928375424a2f162f97c310c7" + integrity sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ== + "@types/node@^17.0.31": version "17.0.31" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.31.tgz#a5bb84ecfa27eec5e1c802c6bbf8139bdb163a5d" @@ -639,6 +644,11 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-saver@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38" + integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA== + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"