new additions : What's new
✅ Rust PTY backend (portable-pty) - pty_spawn / pty_write / pty_resize / pty_kill commands - Output streamed via pty:data Tauri events (base64-encoded bytes) - Spawns /bin/bash (falls back to /bin/bash), starts in /home/code ✅ xterm.js terminal card (full ANSI + true color) ✅ Toolbar (top-left) with + Note / + Terminal ✅ Shared useDragHandle hook (notes + terminals) ✅ Terminal font-size scales with canvas zoom → crisp at any zoom ✅ Terminal auto-refits when card resizes or zoom changes
This commit is contained in:
22
src/App.tsx
22
src/App.tsx
@@ -7,20 +7,14 @@ const initialCards: Card[] = [
|
||||
kind: "note",
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 320,
|
||||
height: 180,
|
||||
z: 0,
|
||||
text: "Welcome to Infinite.\n\nPan: middle-drag or space+drag.\nZoom: Ctrl+wheel.",
|
||||
},
|
||||
{
|
||||
id: "todo",
|
||||
kind: "note",
|
||||
x: 600,
|
||||
y: 320,
|
||||
width: 260,
|
||||
height: 140,
|
||||
z: 0,
|
||||
text: "Next: terminal cards, then X11 embedding.",
|
||||
width: 340,
|
||||
height: 200,
|
||||
z: 1,
|
||||
text:
|
||||
"Welcome to Infinite.\n\n" +
|
||||
"Pan: middle-drag or Space + drag\n" +
|
||||
"Zoom: Ctrl + scroll\n\n" +
|
||||
"Use the toolbar to add notes and terminals.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import type { Card, Viewport } from "./types";
|
||||
import { ViewportContext } from "./viewport";
|
||||
import { NoteCardView } from "./cards/NoteCardView";
|
||||
import { TerminalCardView } from "./cards/TerminalCardView";
|
||||
import "./canvas.css";
|
||||
|
||||
const MIN_SCALE = 0.1;
|
||||
@@ -20,6 +21,9 @@ export function Canvas({ initialCards }: CanvasProps) {
|
||||
const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>(
|
||||
null,
|
||||
);
|
||||
const maxZRef = useRef(
|
||||
initialCards.reduce((m, c) => Math.max(m, c.z), 0),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -36,23 +40,17 @@ export function Canvas({ initialCards }: CanvasProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Suppress WebKit/GTK middle-click defaults (autoscroll on some platforms,
|
||||
// paste primary selection on Linux). Must be a native listener — React's
|
||||
// synthetic onPointerDown.preventDefault doesn't reliably stop these.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const stopMiddle = (e: MouseEvent) => {
|
||||
if (e.button === 1) e.preventDefault();
|
||||
};
|
||||
const stopAux = (e: MouseEvent) => {
|
||||
if (e.button === 1) e.preventDefault();
|
||||
};
|
||||
el.addEventListener("mousedown", stopMiddle);
|
||||
el.addEventListener("auxclick", stopAux);
|
||||
el.addEventListener("auxclick", stopMiddle);
|
||||
return () => {
|
||||
el.removeEventListener("mousedown", stopMiddle);
|
||||
el.removeEventListener("auxclick", stopAux);
|
||||
el.removeEventListener("auxclick", stopMiddle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -74,8 +72,6 @@ export function Canvas({ initialCards }: CanvasProps) {
|
||||
const isPan = e.button === 1 || (e.button === 0 && spaceHeld);
|
||||
if (!isPan) return;
|
||||
e.preventDefault();
|
||||
// Capture on the container, not e.target — survives card re-renders and
|
||||
// avoids odd behavior when the click lands on a textarea or child element.
|
||||
containerRef.current?.setPointerCapture(e.pointerId);
|
||||
panState.current = { startX: e.clientX, startY: e.clientY, vpX: vp.x, vpY: vp.y };
|
||||
};
|
||||
@@ -93,7 +89,7 @@ export function Canvas({ initialCards }: CanvasProps) {
|
||||
try {
|
||||
containerRef.current?.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// capture may already be released if pointercancel fired
|
||||
// already released
|
||||
}
|
||||
panState.current = null;
|
||||
}
|
||||
@@ -103,6 +99,41 @@ export function Canvas({ initialCards }: CanvasProps) {
|
||||
setCards((cs) => cs.map((c) => (c.id === id ? ({ ...c, ...patch } as Card) : c)));
|
||||
};
|
||||
|
||||
const addCardAtCenter = (build: (id: string, x: number, y: number, z: number) => Card) => {
|
||||
const w = window.innerWidth / 2;
|
||||
const h = window.innerHeight / 2;
|
||||
const cx = (w - vp.x) / vp.scale;
|
||||
const cy = (h - vp.y) / vp.scale;
|
||||
const id = crypto.randomUUID();
|
||||
maxZRef.current += 1;
|
||||
const card = build(id, cx, cy, maxZRef.current);
|
||||
setCards((cs) => [...cs, card]);
|
||||
};
|
||||
|
||||
const addNote = () =>
|
||||
addCardAtCenter((id, cx, cy, z) => ({
|
||||
id,
|
||||
kind: "note",
|
||||
x: cx - 160,
|
||||
y: cy - 90,
|
||||
width: 320,
|
||||
height: 180,
|
||||
z,
|
||||
text: "",
|
||||
}));
|
||||
|
||||
const addTerminal = () =>
|
||||
addCardAtCenter((id, cx, cy, z) => ({
|
||||
id,
|
||||
kind: "terminal",
|
||||
x: cx - 280,
|
||||
y: cy - 180,
|
||||
width: 560,
|
||||
height: 360,
|
||||
z,
|
||||
ptyId: "",
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -125,9 +156,16 @@ export function Canvas({ initialCards }: CanvasProps) {
|
||||
if (c.kind === "note") {
|
||||
return <NoteCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />;
|
||||
}
|
||||
if (c.kind === "terminal") {
|
||||
return <TerminalCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</ViewportContext.Provider>
|
||||
<div className="toolbar">
|
||||
<button onClick={addNote}>+ Note</button>
|
||||
<button onClick={addTerminal}>+ Terminal</button>
|
||||
</div>
|
||||
<div className="canvas-hud">
|
||||
<span>x {vp.x.toFixed(0)}</span>
|
||||
<span>y {vp.y.toFixed(0)}</span>
|
||||
|
||||
@@ -78,3 +78,43 @@
|
||||
font-size: 1em;
|
||||
padding: 0.77em;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
background: #1d1d23;
|
||||
padding: calc(4px * var(--scale, 1));
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-body .xterm,
|
||||
.terminal-body .xterm-viewport,
|
||||
.terminal-body .xterm-screen {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 5px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef } from "react";
|
||||
import type { NoteCard } from "../types";
|
||||
import { useViewport } from "../viewport";
|
||||
import { useDragHandle } from "../useDragHandle";
|
||||
|
||||
interface Props {
|
||||
card: NoteCard;
|
||||
@@ -11,63 +11,24 @@ const BODY_FONT_BASE = 13;
|
||||
|
||||
export function NoteCardView({ card, onUpdate }: Props) {
|
||||
const vp = useViewport();
|
||||
const dragState = useRef<{ startX: number; startY: number; cardX: number; cardY: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onHeaderPointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
dragState.current = { startX: e.clientX, startY: e.clientY, cardX: card.x, cardY: card.y };
|
||||
};
|
||||
|
||||
const onHeaderPointerMove = (e: React.PointerEvent) => {
|
||||
const ds = dragState.current;
|
||||
if (!ds) return;
|
||||
const dx = (e.clientX - ds.startX) / vp.scale;
|
||||
const dy = (e.clientY - ds.startY) / vp.scale;
|
||||
onUpdate({ x: ds.cardX + dx, y: ds.cardY + dy });
|
||||
};
|
||||
|
||||
const onHeaderPointerUp = (e: React.PointerEvent) => {
|
||||
if (dragState.current) {
|
||||
try {
|
||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// already released
|
||||
}
|
||||
dragState.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const screenLeft = vp.x + card.x * vp.scale;
|
||||
const screenTop = vp.y + card.y * vp.scale;
|
||||
const screenW = card.width * vp.scale;
|
||||
const screenH = card.height * vp.scale;
|
||||
const drag = useDragHandle(card, (p) => onUpdate(p));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card note-card"
|
||||
style={
|
||||
{
|
||||
left: screenLeft,
|
||||
top: screenTop,
|
||||
width: screenW,
|
||||
height: screenH,
|
||||
left: vp.x + card.x * vp.scale,
|
||||
top: vp.y + card.y * vp.scale,
|
||||
width: card.width * vp.scale,
|
||||
height: card.height * vp.scale,
|
||||
zIndex: card.z,
|
||||
fontSize: BODY_FONT_BASE * vp.scale,
|
||||
"--scale": vp.scale,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="card-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
onPointerMove={onHeaderPointerMove}
|
||||
onPointerUp={onHeaderPointerUp}
|
||||
onPointerCancel={onHeaderPointerUp}
|
||||
>
|
||||
<div className="card-header" {...drag}>
|
||||
note
|
||||
</div>
|
||||
<div className="card-body">
|
||||
|
||||
130
src/canvas/cards/TerminalCardView.tsx
Normal file
130
src/canvas/cards/TerminalCardView.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { TerminalCard } from "../types";
|
||||
import { useViewport } from "../viewport";
|
||||
import { useDragHandle } from "../useDragHandle";
|
||||
import { pty, base64ToBytes, stringToBase64 } from "../../ipc";
|
||||
|
||||
const BASE_FONT_SIZE = 13;
|
||||
|
||||
interface Props {
|
||||
card: TerminalCard;
|
||||
onUpdate: (patch: Partial<TerminalCard>) => void;
|
||||
}
|
||||
|
||||
export function TerminalCardView({ card, onUpdate }: Props) {
|
||||
const vp = useViewport();
|
||||
const drag = useDragHandle(card, (p) => onUpdate(p));
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const ptyIdRef = useRef<string>(card.ptyId);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let unlistenData: (() => void) | undefined;
|
||||
let unlistenExit: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const term = new Terminal({
|
||||
fontSize: BASE_FONT_SIZE * vp.scale,
|
||||
fontFamily: 'Menlo, "Cascadia Code", Consolas, monospace',
|
||||
theme: {
|
||||
background: "#1d1d23",
|
||||
foreground: "#e8e8ec",
|
||||
cursor: "#e8e8ec",
|
||||
},
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(containerRef.current!);
|
||||
fit.fit();
|
||||
termRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
(async () => {
|
||||
let id = ptyIdRef.current;
|
||||
if (!id) {
|
||||
id = await pty.spawn({ cols: term.cols, rows: term.rows });
|
||||
if (disposed) {
|
||||
await pty.kill(id);
|
||||
return;
|
||||
}
|
||||
ptyIdRef.current = id;
|
||||
onUpdate({ ptyId: id });
|
||||
}
|
||||
|
||||
unlistenData = await pty.onData((e) => {
|
||||
if (e.id !== id) return;
|
||||
term.write(base64ToBytes(e.data));
|
||||
});
|
||||
unlistenExit = await pty.onExit((e) => {
|
||||
if (e.id !== id) return;
|
||||
term.write("\r\n\x1b[2m[process exited]\x1b[0m\r\n");
|
||||
});
|
||||
|
||||
term.onData((data) => {
|
||||
pty.write(id!, stringToBase64(data)).catch((err) => console.error("pty_write", err));
|
||||
});
|
||||
term.onResize(({ cols, rows }) => {
|
||||
pty.resize(id!, cols, rows).catch(() => {});
|
||||
});
|
||||
|
||||
setReady(true);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlistenData?.();
|
||||
unlistenExit?.();
|
||||
term.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update xterm font size when canvas zoom changes so text stays crisp.
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const term = termRef.current;
|
||||
const fit = fitRef.current;
|
||||
if (!term || !fit) return;
|
||||
term.options.fontSize = BASE_FONT_SIZE * vp.scale;
|
||||
fit.fit();
|
||||
}, [vp.scale, ready]);
|
||||
|
||||
// Refit on card resize.
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
fitRef.current?.fit();
|
||||
}, [card.width, card.height, ready]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card terminal-card"
|
||||
style={
|
||||
{
|
||||
left: vp.x + card.x * vp.scale,
|
||||
top: vp.y + card.y * vp.scale,
|
||||
width: card.width * vp.scale,
|
||||
height: card.height * vp.scale,
|
||||
zIndex: card.z,
|
||||
"--scale": vp.scale,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="card-header" {...drag}>
|
||||
terminal
|
||||
</div>
|
||||
<div
|
||||
className="card-body terminal-body"
|
||||
ref={containerRef}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/canvas/useDragHandle.ts
Normal file
47
src/canvas/useDragHandle.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useRef } from "react";
|
||||
import { useViewport } from "./viewport";
|
||||
|
||||
interface Pos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function useDragHandle(card: Pos, onUpdate: (p: Pos) => void) {
|
||||
const vp = useViewport();
|
||||
const dragRef = useRef<{ startX: number; startY: number; cardX: number; cardY: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { startX: e.clientX, startY: e.clientY, cardX: card.x, cardY: card.y };
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
const dx = (e.clientX - d.startX) / vp.scale;
|
||||
const dy = (e.clientY - d.startY) / vp.scale;
|
||||
onUpdate({ x: d.cardX + dx, y: d.cardY + dy });
|
||||
};
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
if (dragRef.current) {
|
||||
try {
|
||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// already released
|
||||
}
|
||||
dragRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel: onPointerUp,
|
||||
};
|
||||
}
|
||||
50
src/ipc.ts
Normal file
50
src/ipc.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export interface PtySpawnOpts {
|
||||
cols: number;
|
||||
rows: number;
|
||||
shell?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface PtyDataEvent {
|
||||
id: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface PtyExitEvent {
|
||||
id: string;
|
||||
status: number | null;
|
||||
}
|
||||
|
||||
export const pty = {
|
||||
spawn: (opts: PtySpawnOpts) => invoke<string>("pty_spawn", { opts }),
|
||||
write: (id: string, dataBase64: string) =>
|
||||
invoke<void>("pty_write", { id, data: dataBase64 }),
|
||||
resize: (id: string, cols: number, rows: number) =>
|
||||
invoke<void>("pty_resize", { id, cols, rows }),
|
||||
kill: (id: string) => invoke<void>("pty_kill", { id }),
|
||||
|
||||
onData: (handler: (e: PtyDataEvent) => void): Promise<UnlistenFn> =>
|
||||
listen<PtyDataEvent>("pty:data", (e) => handler(e.payload)),
|
||||
onExit: (handler: (e: PtyExitEvent) => void): Promise<UnlistenFn> =>
|
||||
listen<PtyExitEvent>("pty:exit", (e) => handler(e.payload)),
|
||||
};
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function stringToBase64(s: string): string {
|
||||
return bytesToBase64(new TextEncoder().encode(s));
|
||||
}
|
||||
Reference in New Issue
Block a user