Initial commit

This commit is contained in:
Haapy
2026-05-14 21:08:13 +00:00
commit 259959d713
2763 changed files with 1015351 additions and 0 deletions

29
src/App.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { Canvas } from "./canvas/Canvas";
import type { Card } from "./canvas/types";
const initialCards: Card[] = [
{
id: "welcome",
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.",
},
];
export function App() {
return <Canvas initialCards={initialCards} />;
}

116
src/canvas/Canvas.tsx Normal file
View File

@@ -0,0 +1,116 @@
import { useEffect, useRef, useState, useCallback } from "react";
import type { Card, Viewport } from "./types";
import { NoteCardView } from "./cards/NoteCardView";
import "./canvas.css";
const MIN_SCALE = 0.1;
const MAX_SCALE = 4;
const ZOOM_SENSITIVITY = 0.0015;
interface CanvasProps {
initialCards: Card[];
}
export function Canvas({ initialCards }: CanvasProps) {
const [cards, setCards] = useState<Card[]>(initialCards);
const [vp, setVp] = useState<Viewport>({ x: 0, y: 0, scale: 1 });
const [spaceHeld, setSpaceHeld] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>(
null,
);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space" && !e.repeat) setSpaceHeld(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") setSpaceHeld(false);
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
};
}, []);
const onWheel = useCallback(
(e: React.WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const rect = containerRef.current!.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
setVp((prev) => {
const factor = Math.exp(-e.deltaY * ZOOM_SENSITIVITY);
const next = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * factor));
const k = next / prev.scale;
return { x: mx - (mx - prev.x) * k, y: my - (my - prev.y) * k, scale: next };
});
},
[],
);
const onPointerDown = (e: React.PointerEvent) => {
const isPan = e.button === 1 || (e.button === 0 && spaceHeld);
if (!isPan) return;
e.preventDefault();
(e.target as Element).setPointerCapture(e.pointerId);
panState.current = { startX: e.clientX, startY: e.clientY, vpX: vp.x, vpY: vp.y };
};
const onPointerMove = (e: React.PointerEvent) => {
if (!panState.current) return;
const dx = e.clientX - panState.current.startX;
const dy = e.clientY - panState.current.startY;
setVp((prev) => ({ ...prev, x: panState.current!.vpX + dx, y: panState.current!.vpY + dy }));
};
const onPointerUp = (e: React.PointerEvent) => {
if (panState.current) {
(e.target as Element).releasePointerCapture(e.pointerId);
panState.current = null;
}
};
const updateCard = (id: string, patch: Partial<Card>) => {
setCards((cs) => cs.map((c) => (c.id === id ? ({ ...c, ...patch } as Card) : c)));
};
return (
<div
ref={containerRef}
className={`canvas-container ${spaceHeld ? "pan-mode" : ""}`}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
className="canvas-grid"
style={{
backgroundPosition: `${vp.x}px ${vp.y}px`,
backgroundSize: `${40 * vp.scale}px ${40 * vp.scale}px`,
}}
/>
<div
className="canvas-world"
style={{ transform: `translate(${vp.x}px, ${vp.y}px) scale(${vp.scale})` }}
>
{cards.map((c) => {
if (c.kind === "note") {
return <NoteCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />;
}
return null;
})}
</div>
<div className="canvas-hud">
<span>x {vp.x.toFixed(0)}</span>
<span>y {vp.y.toFixed(0)}</span>
<span>{(vp.scale * 100).toFixed(0)}%</span>
</div>
</div>
);
}

82
src/canvas/canvas.css Normal file
View File

@@ -0,0 +1,82 @@
.canvas-container {
position: fixed;
inset: 0;
overflow: hidden;
cursor: default;
background: var(--bg);
}
.canvas-container.pan-mode {
cursor: grab;
}
.canvas-grid {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(circle, var(--bg-grid) 1px, transparent 1px);
background-repeat: repeat;
}
.canvas-world {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
transform-origin: 0 0;
}
.canvas-hud {
position: fixed;
bottom: 12px;
left: 12px;
display: flex;
gap: 12px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.5);
border-radius: 6px;
font-size: 11px;
font-family: ui-monospace, Menlo, monospace;
color: var(--text);
pointer-events: none;
}
.card {
position: absolute;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
overflow: hidden;
display: flex;
flex-direction: column;
}
.card-header {
padding: 6px 10px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--card-border);
font-size: 11px;
cursor: move;
flex-shrink: 0;
}
.card-body {
flex: 1;
overflow: auto;
}
.note-card textarea {
width: 100%;
height: 100%;
background: transparent;
border: none;
outline: none;
resize: none;
color: var(--text);
font-family: inherit;
font-size: 13px;
padding: 10px;
}

View File

@@ -0,0 +1,65 @@
import { useRef } from "react";
import type { NoteCard } from "../types";
interface Props {
card: NoteCard;
onUpdate: (patch: Partial<NoteCard>) => void;
}
export function NoteCardView({ card, onUpdate }: Props) {
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) => {
if (!dragState.current) return;
const worldEl = (e.currentTarget as HTMLElement).closest(".canvas-world") as HTMLElement;
const scale = worldEl ? parseTransformScale(worldEl.style.transform) : 1;
const dx = (e.clientX - dragState.current.startX) / scale;
const dy = (e.clientY - dragState.current.startY) / scale;
onUpdate({ x: dragState.current.cardX + dx, y: dragState.current.cardY + dy });
};
const onHeaderPointerUp = (e: React.PointerEvent) => {
if (dragState.current) {
(e.target as Element).releasePointerCapture(e.pointerId);
dragState.current = null;
}
};
return (
<div
className="card note-card"
style={{ left: card.x, top: card.y, width: card.width, height: card.height, zIndex: card.z }}
>
<div
className="card-header"
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
onPointerCancel={onHeaderPointerUp}
>
note
</div>
<div className="card-body">
<textarea
value={card.text}
onChange={(e) => onUpdate({ text: e.target.value })}
onPointerDown={(e) => e.stopPropagation()}
/>
</div>
</div>
);
}
function parseTransformScale(transform: string): number {
const m = transform.match(/scale\(([^)]+)\)/);
return m ? parseFloat(m[1]) : 1;
}

44
src/canvas/types.ts Normal file
View File

@@ -0,0 +1,44 @@
export type CardId = string;
export type CardKind = "note" | "terminal" | "app" | "thumbnail";
export interface BaseCard {
id: CardId;
kind: CardKind;
x: number;
y: number;
width: number;
height: number;
z: number;
}
export interface NoteCard extends BaseCard {
kind: "note";
text: string;
}
export interface TerminalCard extends BaseCard {
kind: "terminal";
ptyId: string;
}
export interface AppCard extends BaseCard {
kind: "app";
xWindowId: number;
command: string;
title?: string;
}
export interface ThumbnailCard extends BaseCard {
kind: "thumbnail";
refCardId: CardId;
label: string;
}
export type Card = NoteCard | TerminalCard | AppCard | ThumbnailCard;
export interface Viewport {
x: number;
y: number;
scale: number;
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

27
src/styles.css Normal file
View File

@@ -0,0 +1,27 @@
:root {
--bg: #1a1a1f;
--bg-grid: #25252c;
--card-bg: #2a2a32;
--card-border: #3a3a45;
--text: #e8e8ec;
--accent: #6a8cff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--text);
user-select: none;
}