Added Markdown preview
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
111
src/canvas/cards/markdownLivePreview.ts
Normal file
111
src/canvas/cards/markdownLivePreview.ts
Normal 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 },
|
||||
);
|
||||
97
src/canvas/cards/useMarkdownEditor.ts
Normal file
97
src/canvas/cards/useMarkdownEditor.ts
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
19
src/ipc.ts
19
src/ipc.ts
@@ -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)),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user