Icons — toolbar buttons are now 32×32 line-icon buttons (paper/document for note, terminal-prompt for terminal, globe

for website). Tooltips on hover show label + shortcut. SVGs are inline in src/canvas/icons.tsx, using currentColor so
  they inherit the text color.

  Embed website — new WebCard kind:
  - Click the globe → prompt() for URL (auto-prepends https:// if missing).
  - Card layout: header (title or hostname) → URL bar (monospace, Enter to navigate, Esc to cancel) → iframe sandboxed
  with allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox.
  - Right-click the header to rename. Sites with X-Frame-Options: DENY won't render — that's the iframe-route caveat I
  mentioned; we can upgrade to a real Tauri Webview later if too many sites are blocked.

  Shortcuts — all gated on !isInEditable(activeElement) so they don't fire while typing in CodeMirror, an input, or
  xterm:
  - Ctrl/Cmd+D — duplicate the top-z card (terminals get a fresh shell, same position+28px, same title)
  - Ctrl/Cmd+N — new note
  - Ctrl/Cmd+T — new terminal
  - Delete / Backspace — close the top-z card

  "Top-z card" = the one most recently clicked or just created, which matches the "click to focus" model we already
  have. To duplicate a terminal: click its header to focus, then Ctrl+D — xterm itself doesn't get the keystroke because
   the header isn't an editable target.
This commit is contained in:
Haapy
2026-05-15 12:07:48 +00:00
parent 66ea8b2b69
commit 366aaa6ef4
8 changed files with 399 additions and 40 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Infinite</title> <title>Infinite</title>
<script type="module" crossorigin src="/assets/index-DaPgpxZP.js"></script> <script type="module" crossorigin src="/assets/index-CO8hCyPF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJpJgVAz.css"> <link rel="stylesheet" crossorigin href="/assets/index-B8MxvBkv.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -3,6 +3,8 @@ import type { BoardState, Card, Viewport } from "./types";
import { ViewportContext } from "./viewport"; import { ViewportContext } from "./viewport";
import { NoteCardView } from "./cards/NoteCardView"; import { NoteCardView } from "./cards/NoteCardView";
import { TerminalCardView } from "./cards/TerminalCardView"; import { TerminalCardView } from "./cards/TerminalCardView";
import { WebCardView } from "./cards/WebCardView";
import { GlobeIcon, NoteIcon, TerminalIcon } from "./icons";
import { pty } from "../ipc"; import { pty } from "../ipc";
import "./canvas.css"; import "./canvas.css";
@@ -168,6 +170,97 @@ export function Canvas({ initial, onChange }: CanvasProps) {
ptyId: "", ptyId: "",
})); }));
const addWeb = () => {
const input = window.prompt("Open URL:", "https://");
if (!input) return;
const trimmed = input.trim();
if (!trimmed || trimmed === "https://") return;
const url = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
addCardAtCenter((id, cx, cy, z) => ({
id,
kind: "web",
x: cx - 360,
y: cy - 240,
width: 720,
height: 480,
z,
url,
}));
};
const duplicateCard = (id: string) => {
setCards((cs) => {
const src = cs.find((c) => c.id === id);
if (!src) return cs;
const offset = 28;
maxZRef.current += 1;
const newId = crypto.randomUUID();
const z = maxZRef.current;
let copy: Card;
if (src.kind === "terminal") {
copy = { ...src, id: newId, x: src.x + offset, y: src.y + offset, z, ptyId: "" };
} else {
copy = { ...src, id: newId, x: src.x + offset, y: src.y + offset, z } as Card;
}
return [...cs, copy];
});
};
// Track the top-z card as the "active" target for keyboard shortcuts.
const topCardRef = useRef<Card | null>(null);
useEffect(() => {
let top: Card | null = null;
for (const c of cards) if (!top || c.z > top.z) top = c;
topCardRef.current = top;
}, [cards]);
useEffect(() => {
const isInEditable = (el: EventTarget | null): boolean => {
const e = el as HTMLElement | null;
if (!e) return false;
if (e.tagName === "INPUT" || e.tagName === "TEXTAREA") return true;
if (e.isContentEditable) return true;
if (e.closest?.(".cm-content")) return true;
if (e.closest?.(".xterm-helper-textarea") || e.closest?.(".xterm")) return true;
return false;
};
const onShortcut = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
if (mod && (e.key === "d" || e.key === "D")) {
if (isInEditable(document.activeElement)) return;
const top = topCardRef.current;
if (!top) return;
e.preventDefault();
duplicateCard(top.id);
return;
}
if (mod && (e.key === "n" || e.key === "N")) {
if (isInEditable(document.activeElement)) return;
e.preventDefault();
addNote();
return;
}
if (mod && (e.key === "t" || e.key === "T")) {
if (isInEditable(document.activeElement)) return;
e.preventDefault();
addTerminal();
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
if (isInEditable(document.activeElement)) return;
const top = topCardRef.current;
if (!top) return;
e.preventDefault();
deleteCard(top.id);
return;
}
};
window.addEventListener("keydown", onShortcut);
return () => window.removeEventListener("keydown", onShortcut);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@@ -209,12 +302,45 @@ export function Canvas({ initial, onChange }: CanvasProps) {
/> />
); );
} }
if (c.kind === "web") {
return (
<WebCardView
key={c.id}
card={c}
onUpdate={(p) => updateCard(c.id, p)}
onClose={() => deleteCard(c.id)}
onFocus={() => bringToFront(c.id)}
/>
);
}
return null; return null;
})} })}
</ViewportContext.Provider> </ViewportContext.Provider>
<div className="toolbar"> <div className="toolbar">
<button onClick={addNote}>+ Note</button> <button
<button onClick={addTerminal}>+ Terminal</button> className="toolbar-btn"
onClick={addNote}
title="New note (Ctrl+N)"
aria-label="New note"
>
<NoteIcon />
</button>
<button
className="toolbar-btn"
onClick={addTerminal}
title="New terminal (Ctrl+T)"
aria-label="New terminal"
>
<TerminalIcon />
</button>
<button
className="toolbar-btn"
onClick={addWeb}
title="Embed website"
aria-label="Embed website"
>
<GlobeIcon />
</button>
</div> </div>
<div className="canvas-hud"> <div className="canvas-hud">
<span>x {vp.x.toFixed(0)}</span> <span>x {vp.x.toFixed(0)}</span>

View File

@@ -388,3 +388,74 @@
.toolbar button:hover { .toolbar button:hover {
border-color: var(--accent); border-color: var(--accent);
} }
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0 !important;
opacity: 0.85;
}
.toolbar-btn:hover {
opacity: 1;
}
.toolbar-btn svg {
display: block;
}
.web-card {
display: flex;
flex-direction: column;
}
.web-url-bar {
flex-shrink: 0;
padding: 0.31em 0.46em;
background: rgba(0, 0, 0, 0.15);
border-bottom: calc(1px * var(--scale, 1)) solid var(--card-border);
}
.web-url-input {
width: 100%;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--card-border);
border-radius: 4px;
color: var(--text);
font-family: ui-monospace, Menlo, monospace;
font-size: 0.85em;
padding: 0.31em 0.46em;
outline: none;
}
.web-url-input:focus {
border-color: var(--accent);
}
.web-body {
background: #fff;
position: relative;
}
.web-frame {
border: 0;
width: 100%;
height: 100%;
display: block;
background: #fff;
}
.web-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text);
opacity: 0.4;
font-size: 0.9em;
background: #1d1d23;
}

View File

@@ -0,0 +1,110 @@
import React, { useEffect, useState } from "react";
import type { WebCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
import { CardHeader } from "./CardHeader";
interface Props {
card: WebCard;
onUpdate: (patch: Partial<WebCard>) => void;
onClose: () => void;
onFocus: () => void;
}
const BODY_FONT_BASE = 13;
function hostnameOf(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
function normalizeUrl(input: string): string {
const t = input.trim();
if (!t) return t;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(t)) return t;
return `https://${t}`;
}
export function WebCardView({ card, onUpdate, onClose, onFocus }: Props) {
const vp = useViewport();
const drag = useDragHandle(card, (p) => onUpdate(p));
const [draft, setDraft] = useState(card.url);
// Re-sync the URL bar when the card's URL changes externally.
useEffect(() => {
setDraft(card.url);
}, [card.url]);
const commit = () => {
const next = normalizeUrl(draft);
if (next && next !== card.url) onUpdate({ url: next });
else if (!next) setDraft(card.url);
};
return (
<div
className="card web-card"
onPointerDownCapture={onFocus}
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,
fontSize: BODY_FONT_BASE * vp.scale,
"--scale": vp.scale,
} as React.CSSProperties
}
>
<CardHeader
title={card.title ?? ""}
placeholder={hostnameOf(card.url) || "Website"}
onTitleChange={(t) => onUpdate({ title: t })}
onClose={onClose}
dragProps={drag}
/>
<div className="web-url-bar" onPointerDown={(e) => e.stopPropagation()}>
<input
className="web-url-input"
value={draft}
spellCheck={false}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(card.url);
(e.target as HTMLInputElement).blur();
}
}}
onBlur={commit}
/>
</div>
<div
className="card-body web-body"
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
{card.url ? (
<iframe
className="web-frame"
src={card.url}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
allow="clipboard-write"
/>
) : (
<div className="web-empty">Enter a URL above</div>
)}
</div>
<ResizeHandles card={card} onUpdate={(p) => onUpdate(p as Partial<WebCard>)} />
</div>
);
}

46
src/canvas/icons.tsx Normal file
View File

@@ -0,0 +1,46 @@
import React from "react";
interface IconProps {
size?: number;
className?: string;
}
const base = (size = 18): React.SVGProps<SVGSVGElement> => ({
viewBox: "0 0 24 24",
width: size,
height: size,
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round",
strokeLinejoin: "round",
});
export function NoteIcon({ size, className }: IconProps) {
return (
<svg {...base(size)} className={className} aria-hidden="true">
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<path d="M14 3v6h6" />
<path d="M8 13h8M8 17h5" />
</svg>
);
}
export function TerminalIcon({ size, className }: IconProps) {
return (
<svg {...base(size)} className={className} aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M7 9l3 3-3 3M13 15h4" />
</svg>
);
}
export function GlobeIcon({ size, className }: IconProps) {
return (
<svg {...base(size)} className={className} aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3a14 14 0 0 1 4 9 14 14 0 0 1-4 9 14 14 0 0 1-4-9 14 14 0 0 1 4-9z" />
</svg>
);
}

View File

@@ -1,6 +1,6 @@
export type CardId = string; export type CardId = string;
export type CardKind = "note" | "terminal" | "thumbnail"; export type CardKind = "note" | "terminal" | "thumbnail" | "web";
export interface BaseCard { export interface BaseCard {
id: CardId; id: CardId;
@@ -30,7 +30,13 @@ export interface ThumbnailCard extends BaseCard {
label: string; label: string;
} }
export type Card = NoteCard | TerminalCard | ThumbnailCard; export interface WebCard extends BaseCard {
kind: "web";
url: string;
title?: string;
}
export type Card = NoteCard | TerminalCard | ThumbnailCard | WebCard;
export interface Viewport { export interface Viewport {
x: number; x: number;