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:
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
4
dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Infinite</title>
|
||||
<script type="module" crossorigin src="/assets/index-DaPgpxZP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJpJgVAz.css">
|
||||
<script type="module" crossorigin src="/assets/index-CO8hCyPF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B8MxvBkv.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { BoardState, Card, Viewport } from "./types";
|
||||
import { ViewportContext } from "./viewport";
|
||||
import { NoteCardView } from "./cards/NoteCardView";
|
||||
import { TerminalCardView } from "./cards/TerminalCardView";
|
||||
import { WebCardView } from "./cards/WebCardView";
|
||||
import { GlobeIcon, NoteIcon, TerminalIcon } from "./icons";
|
||||
import { pty } from "../ipc";
|
||||
import "./canvas.css";
|
||||
|
||||
@@ -168,6 +170,97 @@ export function Canvas({ initial, onChange }: CanvasProps) {
|
||||
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 (
|
||||
<div
|
||||
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;
|
||||
})}
|
||||
</ViewportContext.Provider>
|
||||
<div className="toolbar">
|
||||
<button onClick={addNote}>+ Note</button>
|
||||
<button onClick={addTerminal}>+ Terminal</button>
|
||||
<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 className="canvas-hud">
|
||||
<span>x {vp.x.toFixed(0)}</span>
|
||||
|
||||
@@ -388,3 +388,74 @@
|
||||
.toolbar button:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
110
src/canvas/cards/WebCardView.tsx
Normal file
110
src/canvas/cards/WebCardView.tsx
Normal 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
46
src/canvas/icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export type CardId = string;
|
||||
|
||||
export type CardKind = "note" | "terminal" | "thumbnail";
|
||||
export type CardKind = "note" | "terminal" | "thumbnail" | "web";
|
||||
|
||||
export interface BaseCard {
|
||||
id: CardId;
|
||||
@@ -30,7 +30,13 @@ export interface ThumbnailCard extends BaseCard {
|
||||
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 {
|
||||
x: number;
|
||||
|
||||
Reference in New Issue
Block a user