mirror of
https://github.com/remko/waforth
synced 2025-01-18 22:26:39 +01:00
thurtle: Share & Export
This commit is contained in:
parent
69bb024a5f
commit
b701efd803
7 changed files with 313 additions and 45 deletions
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
10
yarn.lock
10
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"
|
||||
|
|
Loading…
Reference in a new issue