From ee8303ed18ab39124d31c0adcf538483a2a20bf4 Mon Sep 17 00:00:00 2001 From: Desmond Date: Thu, 19 Mar 2026 18:53:24 +0100 Subject: [PATCH 01/13] feat: improve editor (#2) --- apps/web/package.json | 11 + apps/web/src/App.tsx | 1 + apps/web/src/components/CodeMirrorEditor.tsx | 199 +++++++++++++++++ apps/web/src/components/PreviewPane.tsx | 56 ++++- apps/web/src/components/SharePanel.tsx | 206 ++++++++++++++++++ apps/web/src/components/YamlEditor.tsx | 82 ++++--- .../src/components/CanvasControls.tsx | 120 ++++++++++ .../src/components/TopologyCanvas.tsx | 58 ++++- packages/renderer/src/index.ts | 9 +- pnpm-lock.yaml | 188 ++++++++++++++++ 10 files changed, 872 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/components/CodeMirrorEditor.tsx create mode 100644 apps/web/src/components/SharePanel.tsx create mode 100644 packages/renderer/src/components/CanvasControls.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 288b1a7..64b1ae0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,8 +9,19 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.12.2", + "@codemirror/lint": "^6.9.5", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.40.0", "@homelab-stackdoc/core": "workspace:*", "@homelab-stackdoc/renderer": "workspace:*", + "@lezer/highlight": "^1.2.3", + "codemirror": "^6.0.2", + "html2canvas": "^1.4.1", "js-yaml": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7dae66c..706baf9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -117,6 +117,7 @@ export const App: React.FC = () => { expanded={expanded} onToggleExpand={toggleExpand} deviceMap={deviceMap} + yaml={yaml} /> diff --git a/apps/web/src/components/CodeMirrorEditor.tsx b/apps/web/src/components/CodeMirrorEditor.tsx new file mode 100644 index 0000000..f2b3806 --- /dev/null +++ b/apps/web/src/components/CodeMirrorEditor.tsx @@ -0,0 +1,199 @@ +import React, { useRef, useEffect } from "react"; +import { EditorState } from "@codemirror/state"; +import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view"; +import { defaultKeymap, indentWithTab, history, historyKeymap } from "@codemirror/commands"; +import { syntaxHighlighting, bracketMatching, foldGutter, HighlightStyle } from "@codemirror/language"; +import { yaml } from "@codemirror/lang-yaml"; +import { searchKeymap, highlightSelectionMatches } from "@codemirror/search"; +import { autocompletion } from "@codemirror/autocomplete"; +import { lintGutter } from "@codemirror/lint"; +import { tags } from "@lezer/highlight"; + +interface CodeMirrorEditorProps { + value: string; + onChange: (value: string) => void; +} + +const theme = EditorView.theme({ + "&": { + height: "100%", + fontSize: "13px", + fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace", + backgroundColor: "transparent", + }, + ".cm-content": { + caretColor: "#00e5ff", + padding: "16px 0", + }, + ".cm-cursor": { + borderLeftColor: "#00e5ff", + borderLeftWidth: "2px", + }, + "&.cm-focused .cm-cursor": { + borderLeftColor: "#00e5ff", + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { + backgroundColor: "rgba(0, 229, 255, 0.15) !important", + }, + "&.cm-focused": { + outline: "none", + }, + ".cm-gutters": { + backgroundColor: "transparent", + borderRight: "1px solid rgba(0, 229, 255, 0.08)", + color: "#546e7a", + minWidth: "40px", + }, + ".cm-activeLineGutter": { + backgroundColor: "rgba(0, 229, 255, 0.06)", + color: "#b0bec5", + }, + ".cm-activeLine": { + backgroundColor: "rgba(0, 229, 255, 0.04)", + }, + ".cm-foldGutter .cm-gutterElement": { + color: "#546e7a", + cursor: "pointer", + }, + ".cm-line": { + padding: "0 16px", + }, + ".cm-scroller": { + overflow: "auto", + }, + ".cm-scroller::-webkit-scrollbar": { + width: "6px", + }, + ".cm-scroller::-webkit-scrollbar-track": { + background: "transparent", + }, + ".cm-scroller::-webkit-scrollbar-thumb": { + background: "rgba(0, 229, 255, 0.15)", + borderRadius: "3px", + }, +}); + +const highlightColors = HighlightStyle.define([ + { tag: tags.keyword, color: "#00e5ff", fontWeight: "bold" }, + { tag: tags.atom, color: "#d500f9" }, + { tag: tags.bool, color: "#d500f9" }, + { tag: tags.null, color: "#78909c" }, + { tag: tags.number, color: "#ffab00" }, + { tag: tags.string, color: "#a5d6a7" }, + { tag: tags.comment, color: "#546e7a", fontStyle: "italic" }, + { tag: tags.meta, color: "#90a4ae" }, + { tag: tags.propertyName, color: "#4dd0e1" }, + { tag: tags.definition(tags.propertyName), color: "#4dd0e1" }, + { tag: tags.typeName, color: "#ffab00" }, + { tag: tags.punctuation, color: "#78909c" }, + { tag: tags.separator, color: "#78909c" }, + { tag: tags.operator, color: "#78909c" }, + { tag: tags.variableName, color: "#e0f7fa" }, + { tag: tags.content, color: "#e0f7fa" }, + { tag: tags.name, color: "#4dd0e1" }, +]); + +const syntaxColors = EditorView.theme({ + // YAML keys + ".cm-propertyName": { color: "#00e5ff" }, + ".cm-string": { color: "#00e676" }, + ".cm-number": { color: "#ffab00" }, + ".cm-bool": { color: "#d500f9" }, + ".cm-null": { color: "#78909c" }, + ".cm-comment": { color: "#455a64" }, + ".cm-meta": { color: "#78909c" }, + ".cm-punctuation": { color: "#546e7a" }, + ".cm-atom": { color: "#d500f9" }, + ".cm-keyword": { color: "#00e5ff" }, + ".cm-typeName": { color: "#ffab00" }, + ".cm-definition": { color: "#00e5ff" }, +}); + +export const CodeMirrorEditor: React.FC = ({ + value, + onChange, +}) => { + const containerRef = useRef(null); + const viewRef = useRef(null); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + // Create editor on mount + useEffect(() => { + if (!containerRef.current) return; + + const updateListener = EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChangeRef.current(update.state.doc.toString()); + } + }); + + const state = EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLine(), + highlightActiveLineGutter(), + history(), + foldGutter(), + bracketMatching(), + highlightSelectionMatches(), + autocompletion(), + lintGutter(), + yaml(), + syntaxHighlighting(highlightColors), + keymap.of([ + ...defaultKeymap, + ...historyKeymap, + ...searchKeymap, + indentWithTab, + ]), + theme, + syntaxColors, + updateListener, + EditorView.lineWrapping, + EditorState.tabSize.of(2), + ], + }); + + const view = new EditorView({ + state, + parent: containerRef.current, + }); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync external value changes (e.g. loading a new file) + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + const currentContent = view.state.doc.toString(); + if (value !== currentContent) { + view.dispatch({ + changes: { + from: 0, + to: currentContent.length, + insert: value, + }, + }); + } + }, [value]); + + return ( +
+ ); +}; diff --git a/apps/web/src/components/PreviewPane.tsx b/apps/web/src/components/PreviewPane.tsx index 0eba3a9..0e46f5a 100644 --- a/apps/web/src/components/PreviewPane.tsx +++ b/apps/web/src/components/PreviewPane.tsx @@ -1,6 +1,8 @@ -import React from "react"; +import React, { useRef, useState, useCallback } from "react"; +import html2canvas from "html2canvas"; import type { PositionedGraph, ValidationError, Device } from "@homelab-stackdoc/core"; import { TopologyCanvas } from "@homelab-stackdoc/renderer"; +import { SharePanel } from "./SharePanel"; interface PreviewPaneProps { graph: PositionedGraph | null; @@ -8,6 +10,7 @@ interface PreviewPaneProps { expanded: Set; onToggleExpand: (id: string) => void; deviceMap: Map; + yaml: string; } export const PreviewPane: React.FC = ({ @@ -16,7 +19,37 @@ export const PreviewPane: React.FC = ({ expanded, onToggleExpand, deviceMap, + yaml, }) => { + const captureRef = useRef(null); + const [isExporting, setIsExporting] = useState(false); + + const handleExportPng = useCallback(async () => { + if (!captureRef.current || !graph) return; + setIsExporting(true); + + try { + const canvas = await html2canvas(captureRef.current, { + backgroundColor: "#080f1e", + scale: 2, + useCORS: true, + logging: false, + // Capture the full container + width: captureRef.current.offsetWidth, + height: captureRef.current.offsetHeight, + }); + + const link = document.createElement("a"); + link.download = `homelab-topology-${Date.now()}.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); + } catch (err) { + console.error("PNG export failed:", err); + } finally { + setIsExporting(false); + } + }, [graph]); + if (errors.some((e) => e.severity === "error") || !graph) { return (
= ({ } return ( - +
+
+ +
+ +
); }; diff --git a/apps/web/src/components/SharePanel.tsx b/apps/web/src/components/SharePanel.tsx new file mode 100644 index 0000000..110067f --- /dev/null +++ b/apps/web/src/components/SharePanel.tsx @@ -0,0 +1,206 @@ +import React, { useState } from "react"; + +const colors = { + background: "rgba(12, 21, 39, 0.95)", + border: "rgba(0, 229, 255, 0.12)", + borderHover: "rgba(0, 229, 255, 0.35)", + primary: "#00e5ff", + green: "#00e676", + textPrimary: "#e0f7fa", + textSecondary: "#78909c", + textMuted: "#455a64", +}; + +const fonts = { + mono: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace", +}; + +interface SharePanelProps { + yaml: string; + onExportPng: () => void; + isExporting: boolean; +} + +const ActionButton: React.FC<{ + onClick: () => void; + icon: React.ReactNode; + label: string; + sublabel?: string; + disabled?: boolean; +}> = ({ onClick, icon, label, sublabel, disabled }) => { + const [hovered, setHovered] = useState(false); + + return ( + + ); +}; + +export const SharePanel: React.FC = ({ + yaml, + onExportPng, + isExporting, +}) => { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const handleCopyYaml = async () => { + try { + await navigator.clipboard.writeText(yaml); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback + const textarea = document.createElement("textarea"); + textarea.value = yaml; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleDownloadYaml = () => { + const blob = new Blob([yaml], { type: "text/yaml" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "homelab-topology.yaml"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ {/* Toggle button */} + + + {/* Dropdown panel */} + {open && ( +
+ + + + } + label={isExporting ? "Exporting..." : "Export as PNG"} + sublabel="High-res image for Reddit" + /> + + + + + } + label={copied ? "Copied!" : "Copy YAML"} + sublabel="Share your config with others" + /> + + + + + } + label="Download YAML" + sublabel="Save as homelab-topology.yaml" + /> +
+ )} +
+ ); +}; diff --git a/apps/web/src/components/YamlEditor.tsx b/apps/web/src/components/YamlEditor.tsx index d7faef5..a88913c 100644 --- a/apps/web/src/components/YamlEditor.tsx +++ b/apps/web/src/components/YamlEditor.tsx @@ -1,5 +1,6 @@ -import React, { useCallback } from "react"; -import type { ValidationError } from "@homelab-topology/core"; +import React from "react"; +import { CodeMirrorEditor } from "./CodeMirrorEditor"; +import type { ValidationError } from "@homelab-stackdoc/core"; interface YamlEditorProps { value: string; @@ -7,18 +8,19 @@ interface YamlEditorProps { errors: ValidationError[]; } +const colors = { + border: "rgba(0, 229, 255, 0.12)", + red: "#ff1744", + amber: "#ffab00", + green: "#00e676", + textSecondary: "#78909c", +}; + export const YamlEditor: React.FC = ({ value, onChange, errors, }) => { - const handleChange = useCallback( - (e: React.ChangeEvent) => { - onChange(e.target.value); - }, - [onChange], - ); - const errorCount = errors.filter((e) => e.severity === "error").length; const warningCount = errors.filter((e) => e.severity === "warning").length; @@ -40,74 +42,66 @@ export const YamlEditor: React.FC = ({ alignItems: "center", justifyContent: "space-between", padding: "8px 16px", - borderBottom: "1px solid rgba(0,229,255,0.12)", - fontSize: 11, - color: "#78909c", + borderBottom: `1px solid ${colors.border}`, + fontSize: 10, + color: colors.textSecondary, + flexShrink: 0, }} > - + homelab.yaml
{errorCount > 0 && ( - + {errorCount} error{errorCount !== 1 ? "s" : ""} )} {warningCount > 0 && ( - + {warningCount} warning{warningCount !== 1 ? "s" : ""} )} {errorCount === 0 && warningCount === 0 && ( - valid + valid )}
{/* Editor */} -