Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow multiple console components #1

Merged
merged 10 commits into from
Jul 17, 2024
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"stackblitz": {
"installDependencies": false,
"startCommand": "pnpm i && pnpm dev"
},
"devDependencies": {
"@antfu/eslint-config": "^2.22.2",
"@ethercorps/sveltekit-og": "^3.0.0",
Expand Down
233 changes: 233 additions & 0 deletions src/lib/components/Console.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
<script lang="ts">
import type { AutoComplete, Item, Status } from "./console/HeadlessConsole.svelte";
import type { ConsoleAPI } from "$py/console/console";
import type { ClipboardEventHandler, KeyboardEventHandler } from "svelte/elements";

import { Err, In, Out, Repr } from "./console";
import HeadlessConsole from "./console/HeadlessConsole.svelte";
import ConsolePrompt from "./ConsolePrompt.svelte";
import Modal from "./Modal.svelte";
import { pyodideReady } from "$lib/stores";
import { patchSource, reformatInputSource } from "$lib/utils/formatSource";
import { onMount } from "svelte";

// eslint-disable-next-line no-undef-init
export let container: HTMLElement | undefined = undefined;

let log: Item[] = [];

const history: string[] = [];
let index = -1;

let input = "";
let inputRef: HTMLInputElement;

let pyConsole: ConsoleAPI;
let complete: AutoComplete;
let status: Status;

let focusedError: { traceback: string; code: string };

function showErrorExplain(index: number) {
if (log[index]?.type !== "err")
return;

const traceback = log[index].text;

const code = log
.slice(0, index)
.map(({ text, type }) => (type === "in" ? reformatInputSource(text) : text))
.join("\n");

focusedError = { traceback, code };
}

let push: (source: string) => Promise<any>;

onMount(async () => {
history.unshift(...(JSON.parse(localStorage.getItem("console-history") || "[]") as string[]));
focusToInput();
});

$: if ($pyodideReady && pyConsole) {
if (location.hash) {
const source = atob(decodeURIComponent(location.hash.slice(1)));
location.hash = "";
pushBlock(source);
}
}

async function pushMany(lines: string[], wait = true, hidden = false, finallySetInput = "") {
let promise: Promise<any> | null = null;
for (const line of lines) {
if (hidden) {
promise = pyConsole.push(line).future;
}
else {
promise && (input = line);
wait && (await promise);
pushHistory(line);
promise = push(line);
}
}
input = finallySetInput;
wait && (await promise);
}

async function pushBlock(source: string, wait = true, hidden = false) {
const lines = patchSource(source.replaceAll("\r\n", "\n")).split("\n");
await pushMany(lines.slice(0, -1), wait, hidden, lines.at(-1));
}

let ready: boolean;

function pushHistory(source: string) {
if (source.trim() && source !== history[0]) {
history.unshift(source);
localStorage.setItem("console-history", JSON.stringify(history.slice(0, 200)));
}
}

function handleInput() {
if (!pyConsole)
return;
push(input);
pushHistory(input);
input = "";
}

function focusToInput(start?: number, end?: number) {
inputRef.scrollIntoView({ block: "center" });
inputRef.focus();
if (start !== undefined) {
requestAnimationFrame(() => inputRef.setSelectionRange(start, end ?? start));
}
}

const onPaste: ClipboardEventHandler<Document> = async (event) => {
const text = event.clipboardData?.getData("text") ?? "";
const textBefore = input.slice(0, inputRef.selectionStart!);
const textAfter = input.slice(inputRef.selectionEnd!);
const distanceToEnd = input.length - inputRef.selectionEnd!;
await pushBlock(textBefore + text + textAfter);
focusToInput(input.length - distanceToEnd);
};

const onKeyDown: KeyboardEventHandler<Document> = (event) => {
if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key.length === 1)
focusToInput();
else if (document.activeElement !== inputRef)
return;

switch (event.key) {
case "ArrowUp": {
const text = history.at(++index);
if (text) {
input = text;
focusToInput(input.length);
}
else {
index = history.length;
}
break;
}

case "ArrowDown": {
index--;
if (index <= -1) {
input = "";
index = -1;
break;
}
input = history.at(index)!;
focusToInput();
break;
}

case "Tab": {
event.preventDefault();
const { selectionStart, selectionEnd } = inputRef;
if (event.shiftKey || selectionStart !== selectionEnd || !input.slice(0, selectionStart!).trim()) {
const startDistance = input.length - selectionStart!;
const endDistance = input.length - selectionEnd!;
if (event.shiftKey)
input = input.replace(/ {0,4}/, "");
else
input = ` ${input}`;
const start = Math.max(0, input.length - startDistance);
const end = Math.max(0, input.length - endDistance);
focusToInput(start, end);
}
else {
const [results, position] = complete(input.slice(0, selectionStart!));
if (results.length === 1) {
const [result] = results;
input = input.slice(0, position) + result + input.slice(selectionEnd!);
focusToInput(position + result.length);
}
}
index = -1;
break;
}

case "Enter": {
handleInput();
index = -1;
break;
}

case "Backspace": {
if (inputRef.selectionStart === 0 && inputRef.selectionEnd === 0 && status === "incomplete") {
input = pyConsole.pop();
history.at(0) === input && history.shift();
index = -1;
event.preventDefault();
}
break;
}

default: {
index = -1;
}
}
};

$: extras = ` ${$$restProps.class ?? "p-3"}`;
</script>

<svelte:document on:keydown={onKeyDown} on:paste|preventDefault={onPaste} />

<div class="w-full @container">
<div class="w-full flex flex-col gap-0.7 overflow-x-scroll whitespace-pre-wrap font-mono [&>div:hover]:(rounded-sm bg-white/2 px-1.7 py-0.6 -mx-1.7 -my-0.6){extras}">

<HeadlessConsole {container} bind:ready bind:log bind:push bind:complete bind:pyConsole bind:status let:loading>
{#each log as { type, text }, index}
{#if type === "out"}
<Out {text} />
{:else if type === "in"}
<In {text} on:click={() => push(text)} />
{:else if type === "err"}
<Err {text} on:click={() => showErrorExplain(index)} />
{:else if type === "repr"}
<Repr {text} />
{/if}
{/each}
<div class="group flex flex-row" class:animate-pulse={loading || !ready}>
<ConsolePrompt prompt={status === "incomplete" ? "..." : ">>>"} />
<!-- svelte-ignore a11y-autofocus -->
<input autofocus bind:this={inputRef} class="w-full bg-transparent outline-none" bind:value={input} type="text" />
</div>
</HeadlessConsole>

</div>
</div>

{#await import("./ErrorExplainer.svelte") then { default: ErrorExplainer }}
<Modal show={focusedError !== undefined}>
<svelte:fragment slot="content">
<svelte:component this={ErrorExplainer} bind:errorInfo={focusedError} {pushBlock} />
</svelte:fragment>
</Modal>
{/await}

<slot {ready} />
16 changes: 16 additions & 0 deletions src/lib/components/FeatureCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts">
export let href: string;
export let icon: string;
export let title: string;
export let description: string;
</script>

<a {href} class="max-h-40 w-full rounded-sm bg-white/2 p-6 ring-transparent transition hover:bg-white/5 hover:ring-(1.2 white/50 inset)">
<div class="flex flex-col gap-3">
<div class="flex flex-row items-center gap-2">
<div class="{icon} text-2xl" />
<h3 class="text-lg">{title}</h3>
</div>
<div class="text-sm op-70">{description}</div>
</div>
</a>
42 changes: 42 additions & 0 deletions src/lib/components/Hero.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<div class="i-carbon-logo-python pointer-events-none absolute right-0 top-1/2 text-30 op-3 -translate-y-1/2 lg:text-90 md:text-70 sm:text-50" />

<div class="h-full min-h-sm flex flex-col justify-between">
<div class="flex flex-row items-center gap-2">
<a class="w-fit flex flex-row select-none items-center gap-1 rounded-full bg-white/5 p-2 pr-2.5 transition hover:(bg-white text-neutral-9)" href="https://github.com/promplate/pyth-on-line">
<div class="i-mdi-github" />
<h2 class="contents">
<div class="flex flex-row gap-0.3 text-xs tracking-wider">
promplate
<span class="op-60">/</span>
pyth-on-line
</div>
</h2>
</a>
<a class="rounded-full bg-white/5 p-2 transition hover:(bg-white text-neutral-9 -rotate-45)" href="https://github.com/promplate/pyth-on-line" target="_blank">
<div class="i-ic-twotone-arrow-forward" />
</a>
</div>

<div class="flex flex-col translate-x-0.5 gap-3 lg:gap-5">
<div class="text-xs">
<span class="op-30">Powered by</span>
<a class="underline underline-(white op-30 offset-2) op-80 transition hover:op-100" href="https://github.com/pyodide/pyodide">@pyodide</a>
<span class="op-30">, a WASM build of CPython</span>
</div>

<h1 class="flex flex-row select-none text-4xl font-bold tracking-wide font-sans -translate-x-0.5">
<span>Pyth</span>
<span>on</span>
<span>line</span>
</h1>

<div>
<h2 class="group w-fit flex flex-row gap-1.5 rounded-sm bg-neutral-1 px-2.5 py-1.5 text-sm text-neutral-9 font-bold font-mono">
AI-supercharged online python IDE
<span class="hidden select-none group-hover:(block after:content-['🚀'])" />
</h2>
</div>
</div>

<div />
</div>
2 changes: 1 addition & 1 deletion src/lib/components/InlineCode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
export let text: string;
</script>

<div tabindex="-1" class="contents [&>pre]:(inline whitespace-pre-wrap) [&_code>span]:min-h-1.4em [&_code]:(flex flex-col gap-0.7) [&_span]:font-mono ![&>pre]:bg-transparent">
<div tabindex="-1" class="contents [&>pre]:(inline whitespace-pre-wrap) [&_code>span]:min-h-[calc(2em-8px)] [&_code]:(flex flex-col gap-0.2em) [&_span]:font-mono ![&>pre]:bg-transparent">
{#await highlight(lang, text)}
{text}
{:then html}
Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/console/HeadlessConsole.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
export let log: Item[] = [];
export let pyConsole: ConsoleAPI;
export let complete: AutoComplete | undefined;
export let container: HTMLElement | undefined;

let loading = 0;

Expand All @@ -42,11 +43,11 @@
let autoscroll = false;

beforeUpdate(() => {
autoscroll = needScroll(document.documentElement, 500);
autoscroll = needScroll(container ?? document.documentElement, 500);
});

afterUpdate(() => {
autoscroll && scrollToBottom(document.documentElement);
autoscroll && scrollToBottom(container ?? document.documentElement);
});

export async function push(source: string) {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/console/In.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
</script>

{#if text !== ""}
<div class="group relative flex flex-row [&_.line]:(min-h-4 lg:min-h-6 sm:min-h-5)">
<div class="min-h-1 flex flex-shrink-0 flex-col gap-0.7 lg:min-h-1.4 sm:min-h-1.2">
<div class="group relative flex flex-row">
<div class="flex flex-col gap-0.2em">
<ConsolePrompt />
{#each Array.from({ length: text.match(/\n/g)?.length ?? 0 }) as _}
<ConsolePrompt prompt="..." />
Expand Down
4 changes: 2 additions & 2 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

<style>
:global(html) {
--uno: bg-neutral-9 text-white flex flex-col items-center overflow-x-hidden;
--uno: bg-neutral-9 text-white overflow-x-hidden;
}

:global(*)::selection {
Expand All @@ -52,7 +52,7 @@
}

:global(body)::-webkit-scrollbar-thumb {
--uno: bg-neutral-7/30 hover:bg-neutral-7/70 rounded-l-sm;
--uno: relative flex flex-col items-center rounded-l-sm bg-neutral-7/30 hover:bg-neutral-7/70;
}

:global(body *)::-webkit-scrollbar {
Expand Down
Loading
Loading