Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 76 additions & 3 deletions src/lib/Session.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import XTerm from "./ui/XTerm.svelte";
import Avatars from "./ui/Avatars.svelte";
import LiveCursor from "./ui/LiveCursor.svelte";
import TouchKeys from "./ui/TouchKeys.svelte";
import { slide } from "./action/slide";
import { TouchZoom, INITIAL_ZOOM } from "./action/touchZoom";
import { arrangeNewTerminal } from "./arrange";
Expand Down Expand Up @@ -54,6 +55,9 @@
];
}

/** Screens narrower than this zoom terminals out to fit their width. */
const NARROW_SCREEN_WIDTH = 640;

let fabricEl: HTMLElement;
let touchZoom: TouchZoom;
let center = [0, 0];
Expand All @@ -63,7 +67,11 @@
let settingsOpen = false; // @hmr:keep
let showNetworkInfo = false; // @hmr:keep

let isTouchDevice = false;
let ctrlKey = false; // Sticky Ctrl modifier from the touch keys.

onMount(() => {
isTouchDevice = window.matchMedia("(pointer: coarse)").matches;
touchZoom = new TouchZoom(fabricEl);
touchZoom.onMove(() => {
center = touchZoom.center;
Expand Down Expand Up @@ -109,6 +117,8 @@
let users: [number, WsUser][] = [];
let shells: [number, WsWinsize][] = [];
let subscriptions = new Set<number>();
let initialFitDone = false; // Whether a terminal was fit on narrow screens.
let fitCreatedTerminal = false; // Fit the next terminal created by this user.

// May be undefined before `users` is first populated.
$: hasWriteAccess = users.find(([uid]) => uid === userId)?.[1]?.canWrite;
Expand Down Expand Up @@ -179,7 +189,9 @@
users = [...users, [id, update]];
}
} else if (message.shells) {
const previousIds = new Set(shells.map(([id]) => id));
shells = message.shells;
fitNewTerminal(shells.filter(([id]) => !previousIds.has(id)));
if (movingIsDone) {
moving = -1;
}
Expand Down Expand Up @@ -285,7 +297,59 @@
}));
const { x, y } = arrangeNewTerminal(existing);
srocket?.send({ create: [x, y] });
touchZoom.moveTo([x, y], INITIAL_ZOOM);
if (window.innerWidth < NARROW_SCREEN_WIDTH) {
fitCreatedTerminal = true; // Fit once the server reports its size.
} else {
touchZoom.moveTo([x, y], INITIAL_ZOOM);
}
}

/** On narrow screens, bring the relevant terminal into view after shells change. */
function fitNewTerminal(added: [number, WsWinsize][]) {
if (window.innerWidth >= NARROW_SCREEN_WIDTH) return;
let target: number | undefined;
if (fitCreatedTerminal && added.length) {
target = added[added.length - 1][0];
} else if (!initialFitDone && shells.length) {
target = shells[shells.length - 1][0]; // Frontmost terminal.
}
if (target === undefined) return;
fitCreatedTerminal = false;
initialFitDone = true;
fitTerminal(target);
}

/** Pan and zoom so a terminal spans the screen width, below the toolbar. */
async function fitTerminal(id: number) {
// Wait for xterm.js to render, so the terminal has its final size.
for (let i = 0; i < 120; i++) {
if (termElements[id]?.querySelector(".xterm-screen")) break;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
const wrapper = termWrappers[id];
const winsize = shells.find(([sid]) => sid === id)?.[1];
if (!wrapper || !winsize) return;

const margin = 8;
const top = 104; // Below the toolbar.
const fitZoom = Math.min(
INITIAL_ZOOM,
(window.innerWidth - 2 * margin) / wrapper.offsetWidth,
);
// Screen position of a terminal is `zoom * (offset + pos - center)`.
const [ox, oy] = getConstantOffset();
touchZoom.moveTo(
[ox + winsize.x - margin / fitZoom, oy + winsize.y - top / fitZoom],
fitZoom,
);
}

/** Applies the sticky Ctrl modifier from the touch keys to typed input. */
function applyCtrlKey(data: Uint8Array): Uint8Array {
if (!ctrlKey || data.length !== 1) return data;
ctrlKey = false;
// Letters and @[\]^_ map to control codes 0x00-0x1f.
return data[0] >= 0x40 ? new Uint8Array([data[0] & 0x1f]) : data;
}

async function handleInput(id: number, data: Uint8Array) {
Expand Down Expand Up @@ -444,6 +508,14 @@
</div>
{/if}

{#if isTouchDevice && focused.length > 0}
<TouchKeys
bind:ctrl={ctrlKey}
on:data={({ detail: data }) =>
handleInput(focused[0], new TextEncoder().encode(data))}
/>
{/if}

<Settings open={settingsOpen} on:close={() => (settingsOpen = false)} />

<ChooseName />
Expand All @@ -459,7 +531,8 @@
style:background-position="{-zoom * center[0]}px {-zoom * center[1]}px"
/>

<div class="py-2">
<!-- On narrow screens, this sits below the centered toolbar. -->
<div class="py-2 mt-16 md:mt-0">
{#if exitReason !== null}
<div class="text-red-400">{exitReason}</div>
{:else if connected}
Expand Down Expand Up @@ -501,7 +574,7 @@
bind:write={writers[id]}
bind:termEl={termElements[id]}
on:data={({ detail: data }) =>
hasWriteAccess && handleInput(id, data)}
hasWriteAccess && handleInput(id, applyCtrlKey(data))}
on:close={() => srocket?.send({ close: id })}
on:shrink={() => {
if (!hasWriteAccess) return;
Expand Down
73 changes: 73 additions & 0 deletions src/lib/ui/TouchKeys.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<!-- @component Extra terminal keys for touch devices, which lack them on the on-screen keyboard. -->
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";

/** Whether the next typed key should be sent with Ctrl held. */
export let ctrl = false;

const dispatch = createEventDispatcher<{ data: string }>();

const keys: [label: string, data: string][] = [
["Esc", "\x1b"],
["Tab", "\t"],
["←", "\x1b[D"],
["↑", "\x1b[A"],
["↓", "\x1b[B"],
["→", "\x1b[C"],
];

// Distance from the bottom of the layout viewport to the top of the
// on-screen keyboard, so the bar stays visible above the keyboard.
let bottom = 0;

onMount(() => {
const viewport = window.visualViewport;
if (!viewport) return;
const update = () => {
bottom = Math.max(
0,
window.innerHeight - viewport.height - viewport.offsetTop,
);
};
update();
viewport.addEventListener("resize", update);
viewport.addEventListener("scroll", update);
return () => {
viewport.removeEventListener("resize", update);
viewport.removeEventListener("scroll", update);
};
});
</script>

<!--
Keys act on pointerdown with preventDefault(), so the terminal keeps focus
and the on-screen keyboard stays open.
-->
<div
class="fixed inset-x-0 z-20 flex gap-1.5 p-1.5 overflow-x-auto bg-zinc-900/95 border-t border-zinc-800"
style:bottom="{bottom}px"
>
<button
class="key"
class:active={ctrl}
on:pointerdown|preventDefault={() => (ctrl = !ctrl)}>Ctrl</button
>
{#each keys as [label, data] (label)}
<button
class="key"
on:pointerdown|preventDefault={() => dispatch("data", data)}
>{label}</button
>
{/each}
</div>

<style lang="postcss">
.key {
@apply flex-1 min-w-[44px] h-10 px-2 rounded-md text-sm font-medium;
@apply bg-zinc-800 text-zinc-200 active:bg-zinc-700 select-none;
}

.key.active {
@apply bg-indigo-600 text-white;
}
</style>
Loading