thurtle: Share & Export

This commit is contained in:
Remko Tronçon 2022-05-20 20:30:56 +02:00
parent 69bb024a5f
commit b701efd803
7 changed files with 313 additions and 45 deletions

View file

@ -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",
}

View file

@ -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",

View file

@ -29,6 +29,7 @@ declare global {
};
g: JSXElement<Omit<SVGGraphicsElement, "transform">> & {
xmlns: "http://www.w3.org/2000/svg";
stroke?: string;
transform?: string;
};
[elemName: string]: any;

View file

@ -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();

View file

@ -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 {

View file

@ -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 = (
<div class="main d-flex flex-column p-2">
<div class="d-flex flex-row flex-grow-1">
<div class="left-pane d-flex flex-column">
<div class="d-flex flex-row">
<select class="form-select mb-2" data-hook="examples"></select>
<div>
<div class="btn-group ms-2">
<div class="d-flex flex-row flex-wrap flex-md-nowrap py-2">
<select class="form-select" data-hook="examples"></select>
<div class="ms-auto me-auto ms-md-2 me-md-0 mt-1 mt-md-0">
<div class="btn-group w-xs-100">
<button
class="btn btn-primary"
aria-label="Run"
data-hook="run"
onclick={run}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-play-fill"
viewBox="0 0 16 16"
>
<path
xmlns="http://www.w3.org/2000/svg"
d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"
/>
</svg>
</button>
<button
type="button"
class="btn btn-light"
class="btn btn-light border"
data-hook="save-btn"
aria-label="Save"
onclick={save}
>
<svg
@ -113,7 +136,7 @@ const rootEl = (
</button>
<button
type="button"
class="btn btn-light dropdown-toggle dropdown-toggle-split"
class="btn btn-light dropdown-toggle dropdown-toggle-split border"
data-bs-toggle="dropdown"
aria-expanded="false"
>
@ -139,39 +162,54 @@ const rootEl = (
Delete
</a>
</li>
<li class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="#" onclick={share}>
Share
</a>
</li>
<li>
<a class="dropdown-item" href="#" onclick={downloadSVG}>
Download as SVG
</a>
</li>
<li>
<a class="dropdown-item" href="#" onclick={downloadPNG}>
Download as PNG
</a>
</li>
</ul>
</div>
</div>
</div>
{editor.el}
<button data-hook="run" class="btn btn-primary mt-2">
Run
</button>
</div>
<div class="d-flex flex-column ms-3 right-pane">
<svg
class="world"
viewBox="0 0 1000 1000"
viewBox="-500 -500 1000 1000"
xmlns="http://www.w3.org/2000/svg"
data-hook="world"
>
<g
fill-opacity="0"
stroke="#000"
xmlns="http://www.w3.org/2000/svg"
transform="translate(500 500)"
>
<g xmlns="http://www.w3.org/2000/svg" id="paths"></g>
id="paths"
></g>
<image
xmlns="http://www.w3.org/2000/svg"
id="turtle"
data-hook="turtle"
width="50"
height="50"
href={turtle}
/>
</g>
</svg>
<form>
<div class="form-group mt-3">
<label>Output</label>
<pre class="output" data-hook="output"></pre>
<pre class="mb-0 output" data-hook="output"></pre>
</div>
</form>
</div>
@ -260,6 +298,55 @@ const rootEl = (
</div>
</div>
</div>
<div class="modal" tabIndex={-1} data-hook="share-modal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Share '<span data-hook="title"></span>'
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<p>Share URL</p>
<input
data-hook="url"
onClick={selectShareURL}
type="text"
readOnly={true}
class="form-control"
/>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Close
</button>
</div>
</div>
</div>
</div>
<div data-hook="saving-modal" class="modal" tabIndex={-1} role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-body text-center">
<p>Saving as PNG ...</p>
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
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<Path> = [{ 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<string>((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();
}
});
////////////////////////////////////////////////////////////////////
function parseQS(sqs = window.location.search) {
const qs: Record<string, string> = {};
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();
loadProgram(DEFAULT_PROGRAM);
}

View file

@ -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"