Added Markdown preview

This commit is contained in:
Haapy
2026-05-15 11:16:45 +00:00
parent 8bc19de599
commit 595666e94b
265 changed files with 114443 additions and 1017 deletions

View File

@@ -1,84 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { xapp, type AppLaunched } from "../ipc";
const PRESETS = [
{ label: "VSCode", cmd: "code --new-window" },
{ label: "Firefox", cmd: "firefox --new-instance" },
{ label: "Files", cmd: "nautilus --new-window" },
{ label: "Calculator", cmd: "gnome-calculator" },
{ label: "xterm", cmd: "xterm" },
];
interface Props {
open: boolean;
onClose: () => void;
onLaunched: (launched: AppLaunched & { command: string }) => void;
}
export function AppLauncher({ open, onClose, onLaunched }: Props) {
const [cmd, setCmd] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setError(null);
setCmd("");
setBusy(false);
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [open]);
if (!open) return null;
const launch = async (command: string) => {
if (!command.trim()) return;
setBusy(true);
setError(null);
try {
const res = await xapp.launch(command);
onLaunched({ ...res, command });
onClose();
} catch (e: unknown) {
setError(String(e));
setBusy(false);
}
};
return (
<>
<div className="launch-dialog-backdrop" onClick={onClose} />
<div className="launch-dialog" onPointerDown={(e) => e.stopPropagation()}>
<h3>Launch app</h3>
<input
ref={inputRef}
value={cmd}
onChange={(e) => setCmd(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") launch(cmd);
if (e.key === "Escape") onClose();
}}
placeholder="e.g. code --new-window /path/to/repo"
disabled={busy}
/>
<div className="launch-dialog-presets">
{PRESETS.map((p) => (
<button key={p.label} onClick={() => launch(p.cmd)} disabled={busy}>
{p.label}
</button>
))}
</div>
{error && <div className="launch-dialog-error">{error}</div>}
<div className="launch-dialog-actions">
<button onClick={onClose} disabled={busy}>
Cancel
</button>
<button className="primary" onClick={() => launch(cmd)} disabled={busy || !cmd.trim()}>
{busy ? "Launching…" : "Launch"}
</button>
</div>
</div>
</>
);
}

View File

@@ -3,9 +3,6 @@ import type { Card, Viewport } from "./types";
import { ViewportContext } from "./viewport";
import { NoteCardView } from "./cards/NoteCardView";
import { TerminalCardView } from "./cards/TerminalCardView";
import { AppCardView } from "./cards/AppCardView";
import { AppLauncher } from "./AppLauncher";
import { xapp } from "../ipc";
import "./canvas.css";
const MIN_SCALE = 0.1;
@@ -20,7 +17,6 @@ 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 [launcherOpen, setLauncherOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panState = useRef<{ startX: number; startY: number; vpX: number; vpY: number } | null>(
null,
@@ -58,19 +54,6 @@ export function Canvas({ initialCards }: CanvasProps) {
};
}, []);
useEffect(() => {
let unlisten: (() => void) | undefined;
xapp.onDestroyed((xid) => {
setCards((cs) => cs.filter((c) => !(c.kind === "app" && c.xWindowId === xid)));
}).then((u) => (unlisten = u));
return () => unlisten?.();
}, []);
// Hide embedded apps while the launcher dialog is open so they don't cover it.
useEffect(() => {
xapp.setAllVisible(!launcherOpen).catch(() => {});
}, [launcherOpen]);
const onWheel = useCallback((e: React.WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
@@ -151,20 +134,6 @@ export function Canvas({ initialCards }: CanvasProps) {
ptyId: "",
}));
const onAppLaunched = (l: { xid: number; title: string; command: string }) =>
addCardAtCenter((id, cx, cy, z) => ({
id,
kind: "app",
x: cx - 400,
y: cy - 260,
width: 800,
height: 520,
z,
xWindowId: l.xid,
command: l.command,
title: l.title,
}));
return (
<div
ref={containerRef}
@@ -190,22 +159,13 @@ export function Canvas({ initialCards }: CanvasProps) {
if (c.kind === "terminal") {
return <TerminalCardView key={c.id} card={c} onUpdate={(p) => updateCard(c.id, p)} />;
}
if (c.kind === "app") {
return <AppCardView 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>
<button onClick={() => setLauncherOpen(true)}>+ App</button>
</div>
<AppLauncher
open={launcherOpen}
onClose={() => setLauncherOpen(false)}
onLaunched={onAppLaunched}
/>
<div className="canvas-hud">
<span>x {vp.x.toFixed(0)}</span>
<span>y {vp.y.toFixed(0)}</span>

View File

@@ -47,7 +47,6 @@
border: calc(1px * var(--scale, 1)) solid var(--card-border);
border-radius: calc(8px * var(--scale, 1));
box-shadow: 0 calc(4px * var(--scale, 1)) calc(16px * var(--scale, 1)) rgba(0, 0, 0, 0.3);
overflow: hidden;
display: flex;
flex-direction: column;
}
@@ -59,24 +58,23 @@
font-size: 0.85em;
cursor: move;
flex-shrink: 0;
overflow: hidden;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.card-body {
flex: 1;
overflow: auto;
overflow: hidden;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
.note-card textarea {
width: 100%;
.note-body .cm-editor {
height: 100%;
background: transparent;
border: none;
}
.note-body .cm-editor.cm-focused {
outline: none;
resize: none;
color: var(--text);
font-family: inherit;
font-size: 1em;
padding: 0.77em;
}
.terminal-body {
@@ -85,125 +83,6 @@
height: 100%;
}
.app-body {
background: transparent;
position: relative;
}
.app-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
background: #2a2a32;
color: var(--text);
}
.app-placeholder-title {
font-size: 1.1em;
opacity: 0.9;
}
.app-placeholder-hint {
font-size: 0.85em;
opacity: 0.5;
}
.launch-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 10px;
padding: 18px;
min-width: 380px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
z-index: 2000;
}
.launch-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1999;
}
.launch-dialog h3 {
margin-bottom: 12px;
font-weight: 500;
font-size: 14px;
}
.launch-dialog input {
width: 100%;
padding: 8px 10px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--card-border);
border-radius: 6px;
font-family: ui-monospace, Menlo, monospace;
font-size: 13px;
outline: none;
}
.launch-dialog input:focus {
border-color: var(--accent);
}
.launch-dialog-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 10px 0;
}
.launch-dialog-presets button {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border: 1px solid var(--card-border);
border-radius: 5px;
padding: 4px 9px;
font-size: 11px;
cursor: pointer;
}
.launch-dialog-presets button:hover {
border-color: var(--accent);
}
.launch-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.launch-dialog-actions button {
background: var(--card-bg);
color: var(--text);
border: 1px solid var(--card-border);
border-radius: 5px;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
}
.launch-dialog-actions button.primary {
background: var(--accent);
border-color: var(--accent);
}
.launch-dialog-error {
color: #ff6464;
font-size: 12px;
margin-top: 8px;
}
.terminal-body .xterm,
.terminal-body .xterm-viewport,
.terminal-body .xterm-screen {

View File

@@ -1,83 +0,0 @@
import { useEffect, useRef } from "react";
import type { AppCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
import { xapp } from "../../ipc";
const HEADER_BASE_PX = 26;
const HIDE_THRESHOLD = 0.4;
interface Props {
card: AppCard;
onUpdate: (patch: Partial<AppCard>) => void;
}
export function AppCardView({ card, onUpdate }: Props) {
const vp = useViewport();
const drag = useDragHandle(card, (p) => onUpdate(p));
const lastVisible = useRef<boolean | null>(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 headerH = HEADER_BASE_PX * vp.scale;
const shouldShow = vp.scale >= HIDE_THRESHOLD;
useEffect(() => {
if (!card.xWindowId) return;
if (lastVisible.current !== shouldShow) {
xapp.setVisible(card.xWindowId, shouldShow).catch(() => {});
lastVisible.current = shouldShow;
}
if (!shouldShow) return;
xapp
.setGeometry(
card.xWindowId,
screenLeft,
screenTop + headerH,
screenW,
Math.max(1, screenH - headerH),
)
.catch(() => {});
}, [card.xWindowId, screenLeft, screenTop, screenW, screenH, headerH, shouldShow]);
useEffect(() => {
return () => {
if (card.xWindowId) xapp.close(card.xWindowId).catch(() => {});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
className="card app-card"
style={
{
left: screenLeft,
top: screenTop,
width: screenW,
height: screenH,
zIndex: card.z,
fontSize: 13 * vp.scale,
"--scale": vp.scale,
} as React.CSSProperties
}
>
<div className="card-header" {...drag}>
{card.title || "app"}
</div>
<div className="card-body app-body">
{!shouldShow && (
<div className="app-placeholder">
<div className="app-placeholder-title">{card.title || card.command}</div>
<div className="app-placeholder-hint">zoom in to view</div>
</div>
)}
</div>
<ResizeHandles card={card} onUpdate={(p) => onUpdate(p as Partial<AppCard>)} />
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { useRef } from "react";
import type { NoteCard } from "../types";
import { useViewport } from "../viewport";
import { useDragHandle } from "../useDragHandle";
import { ResizeHandles } from "./ResizeHandles";
import { useMarkdownEditor } from "./useMarkdownEditor";
interface Props {
card: NoteCard;
@@ -13,6 +15,8 @@ const BODY_FONT_BASE = 13;
export function NoteCardView({ card, onUpdate }: Props) {
const vp = useViewport();
const drag = useDragHandle(card, (p) => onUpdate(p));
const editorRef = useRef<HTMLDivElement>(null);
useMarkdownEditor(editorRef, card.text, (next) => onUpdate({ text: next }));
return (
<div
@@ -32,13 +36,11 @@ export function NoteCardView({ card, onUpdate }: Props) {
<div className="card-header" {...drag}>
note
</div>
<div className="card-body">
<textarea
value={card.text}
onChange={(e) => onUpdate({ text: e.target.value })}
onPointerDown={(e) => e.stopPropagation()}
/>
</div>
<div
className="card-body note-body"
ref={editorRef}
onPointerDown={(e) => e.stopPropagation()}
/>
<ResizeHandles card={card} onUpdate={(p) => onUpdate(p as Partial<NoteCard>)} />
</div>
);

View File

@@ -0,0 +1,111 @@
import { RangeSetBuilder } from "@codemirror/state";
import {
EditorView,
ViewPlugin,
ViewUpdate,
Decoration,
DecorationSet,
} from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
// Obsidian-style live preview: syntax marks (#, *, `, etc.) are hidden on every
// line except the one the cursor is on. Inline styles (bold/italic/code) and
// heading sizes are always applied.
const hideMark = Decoration.replace({});
function build(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const sel = view.state.selection.main;
const activeLine = view.state.doc.lineAt(sel.head);
// Collect candidate ranges from the syntax tree across visible viewport.
type Item = { from: number; to: number; deco: Decoration };
const items: Item[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter(node) {
const name = node.name;
const headingMatch = name.match(/^ATXHeading(\d)$/);
if (headingMatch) {
const line = view.state.doc.lineAt(node.from);
items.push({
from: line.from,
to: line.from,
deco: Decoration.line({ class: `cm-md-h${headingMatch[1]}` }),
});
return;
}
if (name === "StrongEmphasis") {
items.push({ from: node.from, to: node.to, deco: Decoration.mark({ class: "cm-md-strong" }) });
return;
}
if (name === "Emphasis") {
items.push({ from: node.from, to: node.to, deco: Decoration.mark({ class: "cm-md-em" }) });
return;
}
if (name === "InlineCode") {
items.push({ from: node.from, to: node.to, deco: Decoration.mark({ class: "cm-md-inline-code" }) });
return;
}
if (name === "Strikethrough") {
items.push({ from: node.from, to: node.to, deco: Decoration.mark({ class: "cm-md-strike" }) });
return;
}
const isSyntaxMark =
name === "HeaderMark" ||
name === "EmphasisMark" ||
name === "CodeMark" ||
name === "QuoteMark" ||
name === "LinkMark";
if (isSyntaxMark) {
const onActive = node.from >= activeLine.from && node.to <= activeLine.to;
if (!onActive) {
// Hide the mark and the single trailing space after a heading hash.
let to = node.to;
if (name === "HeaderMark") {
const after = view.state.sliceDoc(to, to + 1);
if (after === " ") to += 1;
}
items.push({ from: node.from, to, deco: hideMark });
}
return;
}
},
});
}
// RangeSetBuilder requires sorted input. Line decos are points; sort by
// (from, isLineDeco-first, to) so they precede inline marks at the same offset.
items.sort((a, b) => {
if (a.from !== b.from) return a.from - b.from;
const aLine = a.from === a.to;
const bLine = b.from === b.to;
if (aLine !== bLine) return aLine ? -1 : 1;
return a.to - b.to;
});
for (const it of items) builder.add(it.from, it.to, it.deco);
return builder.finish();
}
export const markdownLivePreview = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = build(view);
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged || u.selectionSet) {
this.decorations = build(u.view);
}
}
},
{ decorations: (v) => v.decorations },
);

View File

@@ -0,0 +1,97 @@
import { useEffect, useRef } from "react";
import { EditorState } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
import { history, defaultKeymap, historyKeymap } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { markdownLivePreview } from "./markdownLivePreview";
const editorTheme = EditorView.theme(
{
"&": {
height: "100%",
backgroundColor: "transparent",
color: "var(--text)",
fontSize: "inherit",
fontFamily: "inherit",
},
".cm-scroller": {
fontFamily: "inherit",
lineHeight: "1.45",
padding: "0.6em 0.77em",
},
".cm-content": { caretColor: "var(--text)" },
".cm-focused": { outline: "none" },
".cm-line": { padding: 0 },
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--text)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": {
backgroundColor: "rgba(255,255,255,0.18)",
},
".cm-md-h1": { fontSize: "1.7em", fontWeight: "600", lineHeight: "1.2" },
".cm-md-h2": { fontSize: "1.45em", fontWeight: "600", lineHeight: "1.25" },
".cm-md-h3": { fontSize: "1.25em", fontWeight: "600" },
".cm-md-h4": { fontSize: "1.1em", fontWeight: "600" },
".cm-md-h5": { fontSize: "1.0em", fontWeight: "600" },
".cm-md-h6": { fontSize: "0.95em", fontWeight: "600", opacity: "0.85" },
".cm-md-strong": { fontWeight: "700" },
".cm-md-em": { fontStyle: "italic" },
".cm-md-strike": { textDecoration: "line-through", opacity: "0.7" },
".cm-md-inline-code": {
fontFamily: "ui-monospace, Menlo, monospace",
background: "rgba(255,255,255,0.08)",
padding: "0 4px",
borderRadius: "3px",
fontSize: "0.92em",
},
},
{ dark: true },
);
export function useMarkdownEditor(
containerRef: React.RefObject<HTMLElement>,
value: string,
onChange: (next: string) => void,
) {
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const view = new EditorView({
parent: el,
state: EditorState.create({
doc: value,
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
markdown(),
markdownLivePreview,
editorTheme,
EditorView.lineWrapping,
EditorView.updateListener.of((u) => {
if (u.docChanged) onChangeRef.current(u.state.doc.toString());
}),
],
}),
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync external value changes (e.g. from another source) without breaking selection.
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current !== value) {
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
});
}
}, [value]);
}

View File

@@ -1,6 +1,6 @@
export type CardId = string;
export type CardKind = "note" | "terminal" | "app" | "thumbnail";
export type CardKind = "note" | "terminal" | "thumbnail";
export interface BaseCard {
id: CardId;
@@ -22,20 +22,13 @@ export interface TerminalCard extends BaseCard {
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 type Card = NoteCard | TerminalCard | ThumbnailCard;
export interface Viewport {
x: number;

View File

@@ -48,22 +48,3 @@ export function base64ToBytes(b64: string): Uint8Array {
export function stringToBase64(s: string): string {
return bytesToBase64(new TextEncoder().encode(s));
}
export interface AppLaunched {
xid: number;
title: string;
}
export const xapp = {
launch: (command: string) =>
invoke<AppLaunched>("app_launch", { opts: { command } }),
setGeometry: (xid: number, x: number, y: number, width: number, height: number) =>
invoke<void>("app_set_geometry", { xid, x, y, width, height }),
setVisible: (xid: number, visible: boolean) =>
invoke<void>("app_set_visible", { xid, visible }),
setAllVisible: (visible: boolean) =>
invoke<void>("app_set_all_visible", { visible }),
close: (xid: number) => invoke<void>("app_close", { xid }),
onDestroyed: (handler: (xid: number) => void): Promise<UnlistenFn> =>
listen<number>("app:destroyed", (e) => handler(e.payload)),
};