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 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
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 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user