| bdb18c9 | | | 1 | <!DOCTYPE html> |
| bdb18c9 | | | 2 | <html lang="en"> |
| bdb18c9 | | | 3 | <head> |
| bdb18c9 | | | 4 | <meta charset="UTF-8"> |
| bdb18c9 | | | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 2a9592c | | | 6 | <title>Grove Collab</title> |
| bf6031c | | | 7 | <link rel="icon" type="image/svg+xml" href="/favicon.svg"> |
| bdb18c9 | | | 8 | <link href="https://fonts.googleapis.com/css2?family=Libre+Caslon+Text:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> |
| 38b80fd | | | 9 | <script src="/mermaid.min.js"></script> |
| bdb18c9 | | | 10 | <script src="/socket.io/socket.io.js"></script> |
| bdb18c9 | | | 11 | <script type="module" id="cm-loader"> |
| bdb18c9 | | | 12 | // CodeMirror 6 — single local bundle (no duplicate @codemirror/state) |
| bdb18c9 | | | 13 | import { |
| bdb18c9 | | | 14 | EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, |
| bdb18c9 | | | 15 | EditorState, |
| bdb18c9 | | | 16 | defaultKeymap, indentWithTab, history, historyKeymap, |
| bdb18c9 | | | 17 | HighlightStyle, syntaxHighlighting, StreamLanguage, bracketMatching, |
| bdb18c9 | | | 18 | tags, |
| bdb18c9 | | | 19 | closeBrackets, |
| bdb18c9 | | | 20 | Y, yCollab |
| bdb18c9 | | | 21 | } from "/cm-bundle.js"; |
| bdb18c9 | | | 22 | |
| bdb18c9 | | | 23 | // ── Custom Mermaid stream parser ── |
| bdb18c9 | | | 24 | const mermaidParser = { |
| bdb18c9 | | | 25 | startState() { return { inBlock: false, blockType: null }; }, |
| bdb18c9 | | | 26 | token(stream, state) { |
| bdb18c9 | | | 27 | // Comments |
| bdb18c9 | | | 28 | if (stream.match(/^%%/)) { stream.skipToEnd(); return "lineComment"; } |
| bdb18c9 | | | 29 | |
| bdb18c9 | | | 30 | // Strings |
| bdb18c9 | | | 31 | if (stream.match(/^"[^"]*"/)) return "string"; |
| bdb18c9 | | | 32 | |
| bdb18c9 | | | 33 | // Diagram-type keywords (at start of doc or after whitespace) |
| bdb18c9 | | | 34 | if (stream.sol() && stream.match(/^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/)) { |
| bdb18c9 | | | 35 | return "keyword"; |
| bdb18c9 | | | 36 | } |
| bdb18c9 | | | 37 | |
| bdb18c9 | | | 38 | // Block keywords |
| bdb18c9 | | | 39 | if (stream.match(/^(subgraph|end|direction|class|state|section|participant|actor|loop|alt|else|opt|par|critical|break|note|over|of|left|right|title)\b/)) { |
| bdb18c9 | | | 40 | return "keyword"; |
| bdb18c9 | | | 41 | } |
| bdb18c9 | | | 42 | |
| bdb18c9 | | | 43 | // ER cardinality symbols |
| bdb18c9 | | | 44 | if (stream.match(/^\|\|--|[{o|]/) || stream.match(/^[|o][|{]--[|o][|{]/)) return "operator"; |
| bdb18c9 | | | 45 | |
| bdb18c9 | | | 46 | // Arrows |
| bdb18c9 | | | 47 | if (stream.match(/^==>/) || stream.match(/^-\.->/) || stream.match(/^\.->/) || stream.match(/^-->/) || stream.match(/^---/) || stream.match(/^-\.-/) || stream.match(/^===/) || stream.match(/^-->/) || stream.match(/^-->/)) return "operator"; |
| bdb18c9 | | | 48 | if (stream.match(/^--|>/)) return "operator"; |
| bdb18c9 | | | 49 | if (stream.match(/^\.\.>/)) return "operator"; |
| bdb18c9 | | | 50 | |
| bdb18c9 | | | 51 | // Pipe labels |"text"| |
| bdb18c9 | | | 52 | if (stream.match(/^\|/)) return "bracket"; |
| bdb18c9 | | | 53 | |
| bdb18c9 | | | 54 | // Brackets for node shapes |
| bdb18c9 | | | 55 | if (stream.match(/^[\[\](){}]/)) return "bracket"; |
| bdb18c9 | | | 56 | |
| bdb18c9 | | | 57 | // Data types in ER diagrams |
| bdb18c9 | | | 58 | if (stream.match(/^(int|text|real|bool|datetime|float|string|void)\b/)) return "typeName"; |
| bdb18c9 | | | 59 | |
| bdb18c9 | | | 60 | // PK, FK, UK markers |
| bdb18c9 | | | 61 | if (stream.match(/^(PK|FK|UK)\b/)) return "annotation"; |
| bdb18c9 | | | 62 | |
| bdb18c9 | | | 63 | // Visibility markers in class diagrams |
| bdb18c9 | | | 64 | if (stream.match(/^[+#~-](?=[A-Za-z])/)) return "operator"; |
| bdb18c9 | | | 65 | |
| bdb18c9 | | | 66 | // Identifiers / words |
| bdb18c9 | | | 67 | if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) return "variableName"; |
| bdb18c9 | | | 68 | |
| bdb18c9 | | | 69 | // Numbers |
| bdb18c9 | | | 70 | if (stream.match(/^[0-9]+(\.[0-9]+)?/)) return "number"; |
| bdb18c9 | | | 71 | |
| bdb18c9 | | | 72 | // Colon |
| bdb18c9 | | | 73 | if (stream.match(/^:/)) return "punctuation"; |
| bdb18c9 | | | 74 | |
| bdb18c9 | | | 75 | // Skip anything else |
| bdb18c9 | | | 76 | stream.next(); |
| bdb18c9 | | | 77 | return null; |
| bdb18c9 | | | 78 | } |
| bdb18c9 | | | 79 | }; |
| bdb18c9 | | | 80 | |
| bdb18c9 | | | 81 | const mermaidLang = StreamLanguage.define(mermaidParser); |
| bdb18c9 | | | 82 | |
| bdb18c9 | | | 83 | // ── Grove theme (matches our CSS vars) ── |
| bdb18c9 | | | 84 | const groveTheme = EditorView.theme({ |
| bdb18c9 | | | 85 | "&": { |
| bdb18c9 | | | 86 | fontSize: "12px", |
| bdb18c9 | | | 87 | fontFamily: "'JetBrains Mono', Menlo, monospace", |
| bdb18c9 | | | 88 | backgroundColor: "#eae6df", |
| bdb18c9 | | | 89 | color: "#2c2824", |
| bdb18c9 | | | 90 | flex: "1", |
| bdb18c9 | | | 91 | overflow: "hidden", |
| bdb18c9 | | | 92 | }, |
| bdb18c9 | | | 93 | "&.cm-focused": { outline: "none" }, |
| bdb18c9 | | | 94 | ".cm-scroller": { overflow: "auto", fontFamily: "inherit" }, |
| bdb18c9 | | | 95 | ".cm-content": { padding: "12px 8px", caretColor: "#4d8a78" }, |
| bdb18c9 | | | 96 | ".cm-gutters": { |
| bdb18c9 | | | 97 | backgroundColor: "#e2ddd5", |
| bdb18c9 | | | 98 | color: "#a09888", |
| bdb18c9 | | | 99 | border: "none", |
| bdb18c9 | | | 100 | borderRight: "1px solid #d9d3ca", |
| bdb18c9 | | | 101 | minWidth: "36px", |
| bdb18c9 | | | 102 | }, |
| bdb18c9 | | | 103 | ".cm-activeLineGutter": { backgroundColor: "#d9d3ca", color: "#4a4540" }, |
| bdb18c9 | | | 104 | ".cm-activeLine": { backgroundColor: "#e2ddd520" }, |
| bdb18c9 | | | 105 | ".cm-cursor": { borderLeftColor: "#4d8a78", borderLeftWidth: "2px" }, |
| bdb18c9 | | | 106 | ".cm-selectionBackground": { backgroundColor: "#4d8a7830 !important" }, |
| bdb18c9 | | | 107 | "&.cm-focused .cm-selectionBackground": { backgroundColor: "#4d8a7840 !important" }, |
| bdb18c9 | | | 108 | ".cm-matchingBracket": { backgroundColor: "#4d8a7830", outline: "1px solid #4d8a7860" }, |
| bdb18c9 | | | 109 | }, { dark: false }); |
| bdb18c9 | | | 110 | |
| bdb18c9 | | | 111 | const groveHighlight = HighlightStyle.define([ |
| bdb18c9 | | | 112 | { tag: tags.keyword, color: "#7c4dff", fontWeight: "500" }, |
| bdb18c9 | | | 113 | { tag: tags.string, color: "#2d6b56" }, |
| bdb18c9 | | | 114 | { tag: tags.lineComment, color: "#a09888", fontStyle: "italic" }, |
| bdb18c9 | | | 115 | { tag: tags.operator, color: "#c56200" }, |
| bdb18c9 | | | 116 | { tag: tags.bracket, color: "#7a746c" }, |
| bdb18c9 | | | 117 | { tag: tags.typeName, color: "#0277bd" }, |
| bdb18c9 | | | 118 | { tag: tags.annotation, color: "#c62828", fontWeight: "600" }, |
| bdb18c9 | | | 119 | { tag: tags.variableName, color: "#2c2824" }, |
| bdb18c9 | | | 120 | { tag: tags.number, color: "#0277bd" }, |
| bdb18c9 | | | 121 | { tag: tags.punctuation, color: "#7a746c" }, |
| bdb18c9 | | | 122 | ]); |
| bdb18c9 | | | 123 | |
| bdb18c9 | | | 124 | // Expose Yjs so the main script can create docs/providers |
| bdb18c9 | | | 125 | window._Y = Y; |
| bdb18c9 | | | 126 | window._yCollab = yCollab; |
| bdb18c9 | | | 127 | |
| bdb18c9 | | | 128 | // Expose a factory so the main script can create the editor |
| bdb18c9 | | | 129 | // If ytext is provided, uses collaborative Yjs mode; otherwise standalone |
| bdb18c9 | | | 130 | window._cmCreateEditor = function(parentEl, initialCode, onChange, ytext, undoManager) { |
| bdb18c9 | | | 131 | const collabMode = ytext && undoManager; |
| bdb18c9 | | | 132 | const extensions = [ |
| bdb18c9 | | | 133 | lineNumbers(), |
| bdb18c9 | | | 134 | highlightActiveLine(), |
| bdb18c9 | | | 135 | highlightActiveLineGutter(), |
| bdb18c9 | | | 136 | drawSelection(), |
| bdb18c9 | | | 137 | bracketMatching(), |
| bdb18c9 | | | 138 | closeBrackets(), |
| bdb18c9 | | | 139 | collabMode ? [] : history(), |
| bdb18c9 | | | 140 | keymap.of([...defaultKeymap, ...(collabMode ? [] : historyKeymap), indentWithTab]), |
| bdb18c9 | | | 141 | mermaidLang, |
| bdb18c9 | | | 142 | groveTheme, |
| bdb18c9 | | | 143 | syntaxHighlighting(groveHighlight), |
| bdb18c9 | | | 144 | EditorView.updateListener.of((update) => { |
| bdb18c9 | | | 145 | if (update.docChanged && onChange) { |
| bdb18c9 | | | 146 | onChange(update.state.doc.toString()); |
| bdb18c9 | | | 147 | } |
| bdb18c9 | | | 148 | }), |
| bdb18c9 | | | 149 | EditorView.lineWrapping, |
| bdb18c9 | | | 150 | ]; |
| bdb18c9 | | | 151 | if (collabMode) { |
| bdb18c9 | | | 152 | extensions.push(yCollab(ytext, null, { undoManager })); |
| bdb18c9 | | | 153 | } |
| bdb18c9 | | | 154 | const view = new EditorView({ |
| bdb18c9 | | | 155 | state: EditorState.create({ |
| bdb18c9 | | | 156 | doc: collabMode ? ytext.toString() : initialCode, |
| bdb18c9 | | | 157 | extensions: extensions.flat(), |
| bdb18c9 | | | 158 | }), |
| bdb18c9 | | | 159 | parent: parentEl, |
| bdb18c9 | | | 160 | }); |
| bdb18c9 | | | 161 | return view; |
| bdb18c9 | | | 162 | }; |
| bdb18c9 | | | 163 | |
| bdb18c9 | | | 164 | // Signal that CM is ready |
| bdb18c9 | | | 165 | window.dispatchEvent(new Event("cm-ready")); |
| bdb18c9 | | | 166 | </script> |
| bdb18c9 | | | 167 | <style> |
| bdb18c9 | | | 168 | :root { |
| bdb18c9 | | | 169 | /* Grove light theme */ |
| bdb18c9 | | | 170 | --bg-page: #faf8f5; |
| bdb18c9 | | | 171 | --bg-card: #f2efe9; |
| bdb18c9 | | | 172 | --bg-inset: #eae6df; |
| bdb18c9 | | | 173 | --bg-hover: #e2ddd5; |
| bdb18c9 | | | 174 | --bg-input: #ffffff; |
| bdb18c9 | | | 175 | |
| bdb18c9 | | | 176 | --text-primary: #2c2824; |
| bdb18c9 | | | 177 | --text-secondary: #4a4540; |
| bdb18c9 | | | 178 | --text-muted: #7a746c; |
| bdb18c9 | | | 179 | --text-faint: #a09888; |
| bdb18c9 | | | 180 | |
| bdb18c9 | | | 181 | --accent: #4d8a78; |
| bdb18c9 | | | 182 | --accent-hover: #3d7a68; |
| bdb18c9 | | | 183 | --accent-subtle: #e8f2ee; |
| bdb18c9 | | | 184 | --accent-text: #ffffff; |
| bdb18c9 | | | 185 | |
| bdb18c9 | | | 186 | --border: #d9d3ca; |
| bdb18c9 | | | 187 | --border-subtle: #e8e3db; |
| bdb18c9 | | | 188 | --divide: #e2ddd580; |
| bdb18c9 | | | 189 | |
| bdb18c9 | | | 190 | --note-bg: #e8f2ee; |
| bdb18c9 | | | 191 | --note-text: #2d6b56; |
| bdb18c9 | | | 192 | --note-border: #b0d4c5; |
| bdb18c9 | | | 193 | |
| bdb18c9 | | | 194 | --drawer-width: 260px; |
| bdb18c9 | | | 195 | } |
| bdb18c9 | | | 196 | |
| bdb18c9 | | | 197 | * { margin: 0; padding: 0; box-sizing: border-box; } |
| bdb18c9 | | | 198 | |
| bdb18c9 | | | 199 | body { |
| bdb18c9 | | | 200 | font-family: 'Libre Caslon Text', Georgia, serif; |
| bdb18c9 | | | 201 | background: var(--bg-page); |
| bdb18c9 | | | 202 | color: var(--text-primary); |
| bdb18c9 | | | 203 | overflow: hidden; |
| bdb18c9 | | | 204 | height: 100vh; |
| bdb18c9 | | | 205 | display: flex; |
| bdb18c9 | | | 206 | flex-direction: column; |
| bdb18c9 | | | 207 | } |
| bdb18c9 | | | 208 | |
| bdb18c9 | | | 209 | code, pre, .mono { |
| bdb18c9 | | | 210 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 211 | } |
| bdb18c9 | | | 212 | |
| bdb18c9 | | | 213 | ::-webkit-scrollbar { width: 6px; height: 6px; } |
| bdb18c9 | | | 214 | ::-webkit-scrollbar-track { background: transparent; } |
| bdb18c9 | | | 215 | ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } |
| bdb18c9 | | | 216 | ::-webkit-scrollbar-thumb:hover { background: var(--text-faint); } |
| bdb18c9 | | | 217 | |
| bdb18c9 | | | 218 | /* Top bar */ |
| bdb18c9 | | | 219 | .topbar { |
| bdb18c9 | | | 220 | display: flex; |
| bdb18c9 | | | 221 | align-items: center; |
| e629dcb | | | 222 | justify-content: space-between; |
| e629dcb | | | 223 | height: 3.5rem; |
| e629dcb | | | 224 | padding: 0 0.75rem; |
| bdb18c9 | | | 225 | border-bottom: 1px solid var(--border); |
| bdb18c9 | | | 226 | flex-shrink: 0; |
| bdb18c9 | | | 227 | z-index: 100; |
| bdb18c9 | | | 228 | } |
| bdb18c9 | | | 229 | |
| e629dcb | | | 230 | @media (min-width: 640px) { |
| e629dcb | | | 231 | .topbar { padding: 0 1.5rem; } |
| e629dcb | | | 232 | } |
| e629dcb | | | 233 | |
| e629dcb | | | 234 | .topbar-left { |
| e629dcb | | | 235 | display: flex; |
| e629dcb | | | 236 | align-items: center; |
| e629dcb | | | 237 | gap: 0.375rem; |
| e629dcb | | | 238 | font-size: 0.875rem; |
| e629dcb | | | 239 | min-width: 0; |
| e629dcb | | | 240 | } |
| e629dcb | | | 241 | |
| e629dcb | | | 242 | @media (min-width: 640px) { |
| e629dcb | | | 243 | .topbar-left { gap: 0.75rem; } |
| e629dcb | | | 244 | } |
| e629dcb | | | 245 | |
| bdb18c9 | | | 246 | .topbar .logo { |
| bdb18c9 | | | 247 | display: flex; |
| bdb18c9 | | | 248 | align-items: center; |
| e629dcb | | | 249 | gap: 0.5rem; |
| e629dcb | | | 250 | text-decoration: none; |
| e629dcb | | | 251 | flex-shrink: 0; |
| e629dcb | | | 252 | } |
| e629dcb | | | 253 | |
| e629dcb | | | 254 | .topbar .logo-text { |
| e629dcb | | | 255 | font-size: 1.125rem; |
| e629dcb | | | 256 | font-weight: 500; |
| bdb18c9 | | | 257 | color: var(--text-primary); |
| e629dcb | | | 258 | margin-left: 0.25rem; |
| bdb18c9 | | | 259 | } |
| bdb18c9 | | | 260 | |
| e629dcb | | | 261 | .topbar .breadcrumb-sep { |
| e629dcb | | | 262 | color: var(--text-faint); |
| e629dcb | | | 263 | } |
| e629dcb | | | 264 | |
| e629dcb | | | 265 | .topbar .breadcrumb-item { |
| bdb18c9 | | | 266 | color: var(--text-muted); |
| e629dcb | | | 267 | text-decoration: none; |
| e629dcb | | | 268 | overflow: hidden; |
| e629dcb | | | 269 | text-overflow: ellipsis; |
| e629dcb | | | 270 | white-space: nowrap; |
| bdb18c9 | | | 271 | } |
| bdb18c9 | | | 272 | |
| e629dcb | | | 273 | .topbar .breadcrumb-item:hover { |
| e629dcb | | | 274 | text-decoration: underline; |
| e629dcb | | | 275 | } |
| e629dcb | | | 276 | |
| e629dcb | | | 277 | .topbar .breadcrumb-current { |
| e629dcb | | | 278 | color: var(--text-primary); |
| e629dcb | | | 279 | overflow: hidden; |
| e629dcb | | | 280 | text-overflow: ellipsis; |
| e629dcb | | | 281 | white-space: nowrap; |
| e629dcb | | | 282 | } |
| e629dcb | | | 283 | |
| e629dcb | | | 284 | .topbar-right { |
| e629dcb | | | 285 | display: flex; |
| e629dcb | | | 286 | align-items: center; |
| e629dcb | | | 287 | gap: 0.5rem; |
| e629dcb | | | 288 | flex-shrink: 0; |
| bdb18c9 | | | 289 | } |
| bdb18c9 | | | 290 | |
| bdb18c9 | | | 291 | .topbar .presence { |
| bdb18c9 | | | 292 | display: flex; |
| bdb18c9 | | | 293 | align-items: center; |
| bdb18c9 | | | 294 | gap: 4px; |
| bdb18c9 | | | 295 | } |
| bdb18c9 | | | 296 | |
| bdb18c9 | | | 297 | .presence-dot { |
| e629dcb | | | 298 | width: 1.75rem; |
| e629dcb | | | 299 | height: 1.75rem; |
| bdb18c9 | | | 300 | border-radius: 50%; |
| bdb18c9 | | | 301 | display: flex; |
| bdb18c9 | | | 302 | align-items: center; |
| bdb18c9 | | | 303 | justify-content: center; |
| e629dcb | | | 304 | font-size: 0.6875rem; |
| e629dcb | | | 305 | font-weight: 600; |
| bdb18c9 | | | 306 | color: #fff; |
| bdb18c9 | | | 307 | cursor: default; |
| bdb18c9 | | | 308 | position: relative; |
| bdb18c9 | | | 309 | } |
| bdb18c9 | | | 310 | |
| bdb18c9 | | | 311 | .presence-dot .tooltip { |
| bdb18c9 | | | 312 | display: none; |
| bdb18c9 | | | 313 | position: absolute; |
| bdb18c9 | | | 314 | bottom: -28px; |
| bdb18c9 | | | 315 | left: 50%; |
| bdb18c9 | | | 316 | transform: translateX(-50%); |
| e629dcb | | | 317 | background: var(--bg-card); |
| bdb18c9 | | | 318 | color: var(--text-primary); |
| bdb18c9 | | | 319 | padding: 2px 8px; |
| bdb18c9 | | | 320 | font-size: 11px; |
| bdb18c9 | | | 321 | white-space: nowrap; |
| bdb18c9 | | | 322 | border: 1px solid var(--border); |
| e629dcb | | | 323 | z-index: 200; |
| bdb18c9 | | | 324 | } |
| bdb18c9 | | | 325 | |
| bdb18c9 | | | 326 | .presence-dot:hover .tooltip { display: block; } |
| bdb18c9 | | | 327 | |
| bdb18c9 | | | 328 | .topbar .actions { |
| bdb18c9 | | | 329 | display: flex; |
| e629dcb | | | 330 | gap: 0.5rem; |
| bdb18c9 | | | 331 | } |
| bdb18c9 | | | 332 | |
| bdb18c9 | | | 333 | .btn { |
| e629dcb | | | 334 | padding: 0.375rem 0.75rem; |
| bdb18c9 | | | 335 | border: 1px solid var(--border); |
| e629dcb | | | 336 | background: none; |
| e629dcb | | | 337 | color: var(--text-muted); |
| e629dcb | | | 338 | font-size: 0.875rem; |
| e629dcb | | | 339 | font-family: inherit; |
| bdb18c9 | | | 340 | cursor: pointer; |
| e629dcb | | | 341 | transition: background-color 0.15s; |
| bdb18c9 | | | 342 | } |
| bdb18c9 | | | 343 | |
| e629dcb | | | 344 | .btn:hover { background: var(--bg-hover); } |
| bdb18c9 | | | 345 | .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } |
| bdb18c9 | | | 346 | .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } |
| bdb18c9 | | | 347 | |
| 7f3fce0 | | | 348 | .topbar .profile-pill { |
| e629dcb | | | 349 | display: flex; |
| e629dcb | | | 350 | align-items: center; |
| 7f3fce0 | | | 351 | gap: 6px; |
| 7f3fce0 | | | 352 | padding: 4px 10px; |
| 7f3fce0 | | | 353 | border: 1px solid var(--border); |
| 7f3fce0 | | | 354 | border-radius: 9999px; |
| 7f3fce0 | | | 355 | background: none; |
| 7f3fce0 | | | 356 | color: var(--text-muted); |
| 7f3fce0 | | | 357 | font-size: 0.8125rem; |
| e629dcb | | | 358 | font-family: inherit; |
| 7f3fce0 | | | 359 | cursor: pointer; |
| 7f3fce0 | | | 360 | } |
| 7f3fce0 | | | 361 | |
| 7f3fce0 | | | 362 | .topbar .profile-pill:hover { |
| 7f3fce0 | | | 363 | background: var(--bg-hover); |
| e629dcb | | | 364 | } |
| e629dcb | | | 365 | |
| bdb18c9 | | | 366 | /* Left drawer */ |
| bdb18c9 | | | 367 | .drawer { |
| bdb18c9 | | | 368 | width: var(--drawer-width); |
| bdb18c9 | | | 369 | background: var(--bg-card); |
| bdb18c9 | | | 370 | border-right: 1px solid var(--border); |
| bdb18c9 | | | 371 | display: flex; |
| bdb18c9 | | | 372 | flex-direction: column; |
| bdb18c9 | | | 373 | flex-shrink: 0; |
| bdb18c9 | | | 374 | overflow: hidden; |
| bdb18c9 | | | 375 | } |
| bdb18c9 | | | 376 | |
| bdb18c9 | | | 377 | |
| bdb18c9 | | | 378 | .drawer-header { |
| bdb18c9 | | | 379 | padding: 12px 16px; |
| e629dcb | | | 380 | font-size: 0.75rem; |
| bdb18c9 | | | 381 | font-weight: 500; |
| bdb18c9 | | | 382 | color: var(--text-muted); |
| bdb18c9 | | | 383 | text-transform: uppercase; |
| bdb18c9 | | | 384 | letter-spacing: 0.5px; |
| bdb18c9 | | | 385 | border-bottom: 1px solid var(--border-subtle); |
| bdb18c9 | | | 386 | flex-shrink: 0; |
| bdb18c9 | | | 387 | } |
| bdb18c9 | | | 388 | |
| bdb18c9 | | | 389 | .drawer-list { |
| bdb18c9 | | | 390 | flex: 1; |
| bdb18c9 | | | 391 | overflow-y: auto; |
| bdb18c9 | | | 392 | padding: 4px 0; |
| bdb18c9 | | | 393 | } |
| bdb18c9 | | | 394 | |
| bdb18c9 | | | 395 | .drawer-section { |
| bdb18c9 | | | 396 | padding: 8px 12px 4px 12px; |
| bdb18c9 | | | 397 | font-size: 10px; |
| bdb18c9 | | | 398 | font-weight: 500; |
| bdb18c9 | | | 399 | color: var(--text-faint); |
| bdb18c9 | | | 400 | text-transform: uppercase; |
| bdb18c9 | | | 401 | letter-spacing: 0.5px; |
| bdb18c9 | | | 402 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 403 | } |
| bdb18c9 | | | 404 | |
| bdb18c9 | | | 405 | .drawer-item { |
| bdb18c9 | | | 406 | display: flex; |
| bdb18c9 | | | 407 | align-items: center; |
| bdb18c9 | | | 408 | gap: 8px; |
| bdb18c9 | | | 409 | padding: 7px 12px 7px 16px; |
| bdb18c9 | | | 410 | font-size: 12px; |
| bdb18c9 | | | 411 | color: var(--text-secondary); |
| bdb18c9 | | | 412 | cursor: pointer; |
| bdb18c9 | | | 413 | transition: background-color 0.12s, color 0.12s; |
| bdb18c9 | | | 414 | white-space: nowrap; |
| bdb18c9 | | | 415 | overflow: hidden; |
| bdb18c9 | | | 416 | text-overflow: ellipsis; |
| bdb18c9 | | | 417 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 418 | position: relative; |
| bdb18c9 | | | 419 | border-left: 3px solid transparent; |
| bdb18c9 | | | 420 | } |
| bdb18c9 | | | 421 | |
| bdb18c9 | | | 422 | .drawer-item:hover { |
| bdb18c9 | | | 423 | background: var(--bg-hover); |
| bdb18c9 | | | 424 | color: var(--text-primary); |
| bdb18c9 | | | 425 | } |
| bdb18c9 | | | 426 | |
| bdb18c9 | | | 427 | .drawer-item.active { |
| bdb18c9 | | | 428 | background: var(--accent-subtle); |
| bdb18c9 | | | 429 | color: var(--accent); |
| bdb18c9 | | | 430 | border-left-color: var(--accent); |
| bdb18c9 | | | 431 | } |
| bdb18c9 | | | 432 | |
| bdb18c9 | | | 433 | .drawer-item .item-number { |
| bdb18c9 | | | 434 | font-size: 10px; |
| bdb18c9 | | | 435 | color: var(--text-faint); |
| bdb18c9 | | | 436 | min-width: 18px; |
| bdb18c9 | | | 437 | flex-shrink: 0; |
| bdb18c9 | | | 438 | } |
| bdb18c9 | | | 439 | |
| bdb18c9 | | | 440 | .drawer-item.active .item-number { |
| bdb18c9 | | | 441 | color: var(--accent); |
| bdb18c9 | | | 442 | } |
| bdb18c9 | | | 443 | |
| bdb18c9 | | | 444 | .drawer-item .item-label { |
| bdb18c9 | | | 445 | overflow: hidden; |
| bdb18c9 | | | 446 | text-overflow: ellipsis; |
| bdb18c9 | | | 447 | flex: 1; |
| bdb18c9 | | | 448 | } |
| bdb18c9 | | | 449 | |
| bdb18c9 | | | 450 | .drawer-item .note-count { |
| bdb18c9 | | | 451 | background: var(--accent-subtle); |
| bdb18c9 | | | 452 | color: var(--accent); |
| bdb18c9 | | | 453 | font-size: 10px; |
| bdb18c9 | | | 454 | font-weight: 500; |
| bdb18c9 | | | 455 | padding: 1px 5px; |
| bdb18c9 | | | 456 | border-radius: 2px; |
| bdb18c9 | | | 457 | border: 1px solid var(--note-border); |
| bdb18c9 | | | 458 | flex-shrink: 0; |
| bdb18c9 | | | 459 | } |
| bdb18c9 | | | 460 | |
| bdb18c9 | | | 461 | .drawer-item .viewer-dots { |
| bdb18c9 | | | 462 | display: flex; |
| bdb18c9 | | | 463 | gap: 3px; |
| bdb18c9 | | | 464 | flex-shrink: 0; |
| bdb18c9 | | | 465 | margin-left: auto; |
| bdb18c9 | | | 466 | padding-left: 6px; |
| bdb18c9 | | | 467 | } |
| bdb18c9 | | | 468 | |
| bdb18c9 | | | 469 | .drawer-item .viewer-dot { |
| bdb18c9 | | | 470 | width: 8px; |
| bdb18c9 | | | 471 | height: 8px; |
| bdb18c9 | | | 472 | border-radius: 50%; |
| bdb18c9 | | | 473 | border: 1px solid rgba(255,255,255,0.5); |
| bdb18c9 | | | 474 | box-shadow: 0 0 0 0.5px rgba(0,0,0,0.1); |
| bdb18c9 | | | 475 | } |
| bdb18c9 | | | 476 | |
| bdb18c9 | | | 477 | /* Main area */ |
| bdb18c9 | | | 478 | .main { |
| bdb18c9 | | | 479 | display: flex; |
| bdb18c9 | | | 480 | flex: 1; |
| bdb18c9 | | | 481 | overflow: hidden; |
| bdb18c9 | | | 482 | } |
| bdb18c9 | | | 483 | |
| bdb18c9 | | | 484 | /* Diagram pane */ |
| bdb18c9 | | | 485 | .diagram-pane { |
| bdb18c9 | | | 486 | flex: 1; |
| bdb18c9 | | | 487 | overflow: hidden; |
| bdb18c9 | | | 488 | position: relative; |
| bdb18c9 | | | 489 | background: var(--bg-page); |
| bdb18c9 | | | 490 | cursor: grab; |
| bdb18c9 | | | 491 | } |
| bdb18c9 | | | 492 | |
| bdb18c9 | | | 493 | .diagram-pane.panning { cursor: grabbing; } |
| bdb18c9 | | | 494 | |
| bdb18c9 | | | 495 | .diagram-pane .mermaid-container { |
| bdb18c9 | | | 496 | transform-origin: 0 0; |
| bdb18c9 | | | 497 | display: inline-block; |
| bdb18c9 | | | 498 | padding: 40px; |
| bdb18c9 | | | 499 | position: relative; |
| bdb18c9 | | | 500 | } |
| bdb18c9 | | | 501 | |
| bdb18c9 | | | 502 | .diagram-pane .mermaid-container svg { |
| bdb18c9 | | | 503 | display: block; |
| bdb18c9 | | | 504 | } |
| bdb18c9 | | | 505 | |
| cee484a | | | 506 | /* Node focus: dim all diagram elements, then un-dim highlighted ones */ |
| cee484a | | | 507 | .diagram-pane.node-focus .mermaid-container svg .node-dimable { |
| cee484a | | | 508 | opacity: 0.1; |
| cee484a | | | 509 | transition: opacity 0.2s ease; |
| cee484a | | | 510 | } |
| cee484a | | | 511 | |
| cee484a | | | 512 | .diagram-pane.node-focus .mermaid-container svg .node-dimable.node-highlight { |
| cee484a | | | 513 | opacity: 1 !important; |
| cee484a | | | 514 | transition: opacity 0.2s ease; |
| cee484a | | | 515 | } |
| cee484a | | | 516 | |
| cee484a | | | 517 | .diagram-pane .mermaid-container svg .node-clickable { |
| cee484a | | | 518 | cursor: pointer; |
| cee484a | | | 519 | } |
| cee484a | | | 520 | |
| bdb18c9 | | | 521 | .bottom-toolbar { |
| bdb18c9 | | | 522 | position: absolute; |
| bdb18c9 | | | 523 | bottom: 12px; |
| bdb18c9 | | | 524 | right: 12px; |
| bdb18c9 | | | 525 | display: flex; |
| bdb18c9 | | | 526 | align-items: center; |
| bdb18c9 | | | 527 | gap: 4px; |
| bdb18c9 | | | 528 | z-index: 20; |
| bdb18c9 | | | 529 | background: var(--bg-card); |
| bdb18c9 | | | 530 | border: 1px solid var(--border); |
| bdb18c9 | | | 531 | border-radius: 6px; |
| bdb18c9 | | | 532 | padding: 3px; |
| bdb18c9 | | | 533 | box-shadow: 0 2px 8px rgba(0,0,0,0.06); |
| bdb18c9 | | | 534 | opacity: 0.7; |
| bdb18c9 | | | 535 | transition: opacity 0.15s; |
| bdb18c9 | | | 536 | } |
| bdb18c9 | | | 537 | |
| bdb18c9 | | | 538 | .bottom-toolbar:hover { |
| bdb18c9 | | | 539 | opacity: 1; |
| bdb18c9 | | | 540 | } |
| bdb18c9 | | | 541 | |
| bdb18c9 | | | 542 | .toolbar-divider { |
| bdb18c9 | | | 543 | width: 1px; |
| bdb18c9 | | | 544 | height: 20px; |
| bdb18c9 | | | 545 | background: var(--border); |
| bdb18c9 | | | 546 | margin: 0 2px; |
| bdb18c9 | | | 547 | } |
| bdb18c9 | | | 548 | |
| bdb18c9 | | | 549 | .mode-btn { |
| bdb18c9 | | | 550 | width: 32px; |
| bdb18c9 | | | 551 | height: 32px; |
| bdb18c9 | | | 552 | border: 1px solid transparent; |
| bdb18c9 | | | 553 | background: transparent; |
| bdb18c9 | | | 554 | color: var(--text-muted); |
| bdb18c9 | | | 555 | border-radius: 4px; |
| bdb18c9 | | | 556 | cursor: pointer; |
| bdb18c9 | | | 557 | display: flex; |
| bdb18c9 | | | 558 | align-items: center; |
| bdb18c9 | | | 559 | justify-content: center; |
| bdb18c9 | | | 560 | transition: background-color 0.12s, color 0.12s, border-color 0.12s; |
| bdb18c9 | | | 561 | } |
| bdb18c9 | | | 562 | |
| bdb18c9 | | | 563 | .mode-btn:hover { |
| bdb18c9 | | | 564 | background: var(--bg-hover); |
| bdb18c9 | | | 565 | color: var(--text-primary); |
| bdb18c9 | | | 566 | } |
| bdb18c9 | | | 567 | |
| bdb18c9 | | | 568 | .mode-btn.active { |
| bdb18c9 | | | 569 | background: var(--accent-subtle); |
| bdb18c9 | | | 570 | color: var(--accent); |
| bdb18c9 | | | 571 | border-color: var(--accent); |
| bdb18c9 | | | 572 | } |
| bdb18c9 | | | 573 | |
| bdb18c9 | | | 574 | .zoom-btn { |
| bdb18c9 | | | 575 | width: 32px; |
| bdb18c9 | | | 576 | height: 32px; |
| bdb18c9 | | | 577 | border: 1px solid transparent; |
| bdb18c9 | | | 578 | background: transparent; |
| bdb18c9 | | | 579 | color: var(--text-secondary); |
| bdb18c9 | | | 580 | border-radius: 4px; |
| bdb18c9 | | | 581 | cursor: pointer; |
| bdb18c9 | | | 582 | font-size: 16px; |
| bdb18c9 | | | 583 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 584 | display: flex; |
| bdb18c9 | | | 585 | align-items: center; |
| bdb18c9 | | | 586 | justify-content: center; |
| bdb18c9 | | | 587 | transition: background-color 0.15s; |
| bdb18c9 | | | 588 | } |
| bdb18c9 | | | 589 | |
| bdb18c9 | | | 590 | .zoom-btn:hover { background: var(--bg-hover); } |
| bdb18c9 | | | 591 | |
| bdb18c9 | | | 592 | .zoom-level { |
| bdb18c9 | | | 593 | height: 32px; |
| bdb18c9 | | | 594 | padding: 0 8px; |
| bdb18c9 | | | 595 | color: var(--text-muted); |
| bdb18c9 | | | 596 | font-size: 11px; |
| bdb18c9 | | | 597 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 598 | display: flex; |
| bdb18c9 | | | 599 | align-items: center; |
| bdb18c9 | | | 600 | } |
| bdb18c9 | | | 601 | |
| bdb18c9 | | | 602 | |
| bdb18c9 | | | 603 | /* Code pane (vertical left sidebar) */ |
| bdb18c9 | | | 604 | .code-pane { |
| bdb18c9 | | | 605 | width: 32px; |
| bdb18c9 | | | 606 | min-width: 32px; |
| bdb18c9 | | | 607 | background: var(--bg-card); |
| bdb18c9 | | | 608 | border-right: 1px solid var(--border); |
| bdb18c9 | | | 609 | flex-shrink: 0; |
| bdb18c9 | | | 610 | overflow: hidden; |
| bdb18c9 | | | 611 | display: flex; |
| bdb18c9 | | | 612 | flex-direction: column; |
| bdb18c9 | | | 613 | position: relative; |
| bdb18c9 | | | 614 | } |
| bdb18c9 | | | 615 | |
| bdb18c9 | | | 616 | .code-pane.open { |
| bdb18c9 | | | 617 | width: 520px; |
| bdb18c9 | | | 618 | min-width: 200px; |
| bdb18c9 | | | 619 | } |
| bdb18c9 | | | 620 | |
| bdb18c9 | | | 621 | .code-pane-header { |
| bdb18c9 | | | 622 | display: flex; |
| bdb18c9 | | | 623 | align-items: center; |
| bdb18c9 | | | 624 | gap: 6px; |
| bdb18c9 | | | 625 | padding: 8px 8px; |
| bdb18c9 | | | 626 | font-size: 11px; |
| bdb18c9 | | | 627 | font-weight: 500; |
| bdb18c9 | | | 628 | color: var(--text-muted); |
| bdb18c9 | | | 629 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 630 | cursor: pointer; |
| bdb18c9 | | | 631 | user-select: none; |
| bdb18c9 | | | 632 | border-bottom: 1px solid var(--border-subtle); |
| bdb18c9 | | | 633 | text-transform: uppercase; |
| bdb18c9 | | | 634 | letter-spacing: 0.5px; |
| bdb18c9 | | | 635 | white-space: nowrap; |
| bdb18c9 | | | 636 | flex-shrink: 0; |
| bdb18c9 | | | 637 | } |
| bdb18c9 | | | 638 | |
| bdb18c9 | | | 639 | .code-pane:not(.open) .code-pane-header { |
| bdb18c9 | | | 640 | writing-mode: vertical-lr; |
| bdb18c9 | | | 641 | text-orientation: mixed; |
| bdb18c9 | | | 642 | border-bottom: none; |
| bdb18c9 | | | 643 | padding: 12px 6px; |
| bdb18c9 | | | 644 | flex: 1; |
| bdb18c9 | | | 645 | justify-content: start; |
| bdb18c9 | | | 646 | align-items: center; |
| bdb18c9 | | | 647 | } |
| bdb18c9 | | | 648 | |
| bdb18c9 | | | 649 | .code-pane:not(.open) .code-pane-toggle { |
| bdb18c9 | | | 650 | transform: rotate(90deg); |
| bdb18c9 | | | 651 | margin-bottom: 2px; |
| bdb18c9 | | | 652 | } |
| bdb18c9 | | | 653 | |
| bdb18c9 | | | 654 | .code-pane-header:hover { background: var(--bg-hover); } |
| bdb18c9 | | | 655 | |
| bdb18c9 | | | 656 | .code-pane-fmt-btn { |
| bdb18c9 | | | 657 | display: none; |
| bdb18c9 | | | 658 | padding: 2px 8px; |
| bdb18c9 | | | 659 | font-size: 10px; |
| bdb18c9 | | | 660 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 661 | background: var(--bg-inset); |
| bdb18c9 | | | 662 | border: 1px solid var(--border); |
| bdb18c9 | | | 663 | border-radius: 3px; |
| bdb18c9 | | | 664 | color: var(--text-muted); |
| bdb18c9 | | | 665 | cursor: pointer; |
| bdb18c9 | | | 666 | text-transform: none; |
| bdb18c9 | | | 667 | letter-spacing: 0; |
| bdb18c9 | | | 668 | transition: background-color 0.12s, color 0.12s; |
| bdb18c9 | | | 669 | } |
| bdb18c9 | | | 670 | |
| bdb18c9 | | | 671 | .code-pane.open .code-pane-fmt-btn { display: block; } |
| bdb18c9 | | | 672 | .code-pane.open .code-pane-text-size { display: flex !important; gap: 2px; align-items: center; } |
| bdb18c9 | | | 673 | .code-pane-fmt-btn:hover { background: var(--accent); color: var(--accent-text); border-color: var(--accent); } |
| bdb18c9 | | | 674 | |
| bdb18c9 | | | 675 | .code-pane-play-btn { |
| bdb18c9 | | | 676 | display: none; |
| bdb18c9 | | | 677 | padding: 2px 10px; |
| bdb18c9 | | | 678 | font-size: 10px; |
| bdb18c9 | | | 679 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 680 | background: var(--accent); |
| bdb18c9 | | | 681 | border: 1px solid var(--accent); |
| bdb18c9 | | | 682 | border-radius: 3px; |
| bdb18c9 | | | 683 | color: var(--accent-text); |
| bdb18c9 | | | 684 | cursor: pointer; |
| bdb18c9 | | | 685 | text-transform: none; |
| bdb18c9 | | | 686 | letter-spacing: 0; |
| bdb18c9 | | | 687 | transition: opacity 0.12s, background-color 0.12s; |
| bdb18c9 | | | 688 | opacity: 0.5; |
| bdb18c9 | | | 689 | } |
| bdb18c9 | | | 690 | .code-pane.open .code-pane-play-btn { display: block; } |
| bdb18c9 | | | 691 | .code-pane-play-btn.dirty { opacity: 1; } |
| bdb18c9 | | | 692 | .code-pane-play-btn:hover { opacity: 1; } |
| bdb18c9 | | | 693 | |
| bdb18c9 | | | 694 | .code-pane-status { |
| bdb18c9 | | | 695 | display: none; |
| bdb18c9 | | | 696 | font-size: 10px; |
| bdb18c9 | | | 697 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 698 | color: var(--text-muted); |
| bdb18c9 | | | 699 | white-space: nowrap; |
| bdb18c9 | | | 700 | cursor: default; |
| bdb18c9 | | | 701 | } |
| bdb18c9 | | | 702 | .code-pane.open .code-pane-status { display: inline-block; } |
| bdb18c9 | | | 703 | .code-pane-status.error { color: #a05050; } |
| bdb18c9 | | | 704 | .code-pane-status.ok { color: var(--accent); } |
| bdb18c9 | | | 705 | |
| bdb18c9 | | | 706 | .code-pane-diagnostics { |
| bdb18c9 | | | 707 | display: none; |
| bdb18c9 | | | 708 | padding: 6px 10px; |
| bdb18c9 | | | 709 | font-size: 11px; |
| bdb18c9 | | | 710 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 711 | color: #a05050; |
| bdb18c9 | | | 712 | background: #a050500a; |
| bdb18c9 | | | 713 | border-top: 1px solid #a0505030; |
| bdb18c9 | | | 714 | flex-shrink: 0; |
| bdb18c9 | | | 715 | overflow-x: auto; |
| bdb18c9 | | | 716 | white-space: pre-wrap; |
| bdb18c9 | | | 717 | word-break: break-word; |
| bdb18c9 | | | 718 | max-height: 120px; |
| bdb18c9 | | | 719 | overflow-y: auto; |
| bdb18c9 | | | 720 | line-height: 1.5; |
| bdb18c9 | | | 721 | } |
| bdb18c9 | | | 722 | .code-pane.open .code-pane-diagnostics.visible { display: block; } |
| bdb18c9 | | | 723 | |
| bdb18c9 | | | 724 | .code-pane-toggle { |
| bdb18c9 | | | 725 | font-size: 10px; |
| bdb18c9 | | | 726 | transition: transform 0.15s; |
| bdb18c9 | | | 727 | display: inline-block; |
| bdb18c9 | | | 728 | } |
| bdb18c9 | | | 729 | |
| bdb18c9 | | | 730 | .code-pane.open .code-pane-toggle { |
| bdb18c9 | | | 731 | transform: rotate(90deg); |
| bdb18c9 | | | 732 | } |
| bdb18c9 | | | 733 | |
| bdb18c9 | | | 734 | .code-pane-editor { |
| bdb18c9 | | | 735 | display: none; |
| bdb18c9 | | | 736 | flex: 1; |
| bdb18c9 | | | 737 | overflow: hidden; |
| bdb18c9 | | | 738 | background: var(--bg-inset); |
| bdb18c9 | | | 739 | } |
| bdb18c9 | | | 740 | |
| bdb18c9 | | | 741 | .code-pane.open .code-pane-editor { |
| bdb18c9 | | | 742 | display: flex; |
| bdb18c9 | | | 743 | } |
| bdb18c9 | | | 744 | |
| bdb18c9 | | | 745 | /* Resize handle */ |
| bdb18c9 | | | 746 | .code-pane-resize { |
| bdb18c9 | | | 747 | position: absolute; |
| bdb18c9 | | | 748 | top: 0; |
| bdb18c9 | | | 749 | right: -3px; |
| bdb18c9 | | | 750 | width: 6px; |
| bdb18c9 | | | 751 | height: 100%; |
| bdb18c9 | | | 752 | cursor: col-resize; |
| bdb18c9 | | | 753 | z-index: 10; |
| bdb18c9 | | | 754 | display: none; |
| bdb18c9 | | | 755 | } |
| bdb18c9 | | | 756 | |
| bdb18c9 | | | 757 | .code-pane.open .code-pane-resize { |
| bdb18c9 | | | 758 | display: block; |
| bdb18c9 | | | 759 | } |
| bdb18c9 | | | 760 | |
| bdb18c9 | | | 761 | .code-pane-resize:hover, |
| bdb18c9 | | | 762 | .code-pane-resize.dragging { |
| bdb18c9 | | | 763 | background: var(--accent); |
| bdb18c9 | | | 764 | opacity: 0.4; |
| bdb18c9 | | | 765 | } |
| bdb18c9 | | | 766 | |
| bdb18c9 | | | 767 | /* Notes sidebar */ |
| bdb18c9 | | | 768 | .notes-sidebar { |
| bdb18c9 | | | 769 | width: 300px; |
| bdb18c9 | | | 770 | min-width: 180px; |
| bdb18c9 | | | 771 | background: var(--bg-card); |
| bdb18c9 | | | 772 | border-left: 1px solid var(--border); |
| bdb18c9 | | | 773 | display: flex; |
| bdb18c9 | | | 774 | flex-direction: column; |
| bdb18c9 | | | 775 | flex-shrink: 0; |
| bdb18c9 | | | 776 | position: relative; |
| bdb18c9 | | | 777 | } |
| bdb18c9 | | | 778 | |
| bdb18c9 | | | 779 | .notes-resize { |
| bdb18c9 | | | 780 | position: absolute; |
| bdb18c9 | | | 781 | top: 0; |
| bdb18c9 | | | 782 | left: -3px; |
| bdb18c9 | | | 783 | width: 6px; |
| bdb18c9 | | | 784 | height: 100%; |
| bdb18c9 | | | 785 | cursor: col-resize; |
| bdb18c9 | | | 786 | z-index: 10; |
| bdb18c9 | | | 787 | } |
| bdb18c9 | | | 788 | |
| bdb18c9 | | | 789 | .notes-resize:hover, |
| bdb18c9 | | | 790 | .notes-resize.dragging { |
| bdb18c9 | | | 791 | background: var(--accent); |
| bdb18c9 | | | 792 | opacity: 0.4; |
| bdb18c9 | | | 793 | } |
| bdb18c9 | | | 794 | |
| bdb18c9 | | | 795 | .notes-header { |
| bdb18c9 | | | 796 | padding: 8px 12px; |
| bdb18c9 | | | 797 | font-size: 11px; |
| bdb18c9 | | | 798 | font-weight: 500; |
| bdb18c9 | | | 799 | color: var(--text-muted); |
| bdb18c9 | | | 800 | border-bottom: 1px solid var(--border-subtle); |
| bdb18c9 | | | 801 | display: flex; |
| bdb18c9 | | | 802 | align-items: center; |
| bdb18c9 | | | 803 | justify-content: space-between; |
| bdb18c9 | | | 804 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 805 | text-transform: uppercase; |
| bdb18c9 | | | 806 | letter-spacing: 0.5px; |
| bdb18c9 | | | 807 | } |
| bdb18c9 | | | 808 | |
| bdb18c9 | | | 809 | .notes-list { |
| bdb18c9 | | | 810 | flex: 1; |
| bdb18c9 | | | 811 | overflow-y: auto; |
| bdb18c9 | | | 812 | padding: 6px; |
| bdb18c9 | | | 813 | } |
| bdb18c9 | | | 814 | |
| bdb18c9 | | | 815 | .notes-list:empty::after { |
| bdb18c9 | | | 816 | content: "No notes yet"; |
| bdb18c9 | | | 817 | display: block; |
| bdb18c9 | | | 818 | text-align: center; |
| bdb18c9 | | | 819 | padding: 32px 16px; |
| bdb18c9 | | | 820 | color: var(--text-faint); |
| bdb18c9 | | | 821 | font-size: 12px; |
| bdb18c9 | | | 822 | font-style: italic; |
| bdb18c9 | | | 823 | } |
| bdb18c9 | | | 824 | |
| bdb18c9 | | | 825 | .note-card { |
| bdb18c9 | | | 826 | background: transparent; |
| bdb18c9 | | | 827 | border: none; |
| bdb18c9 | | | 828 | border-bottom: 1px solid var(--border-subtle); |
| bdb18c9 | | | 829 | border-radius: 0; |
| bdb18c9 | | | 830 | padding: 8px 10px; |
| bdb18c9 | | | 831 | font-size: 13px; |
| bdb18c9 | | | 832 | position: relative; |
| bdb18c9 | | | 833 | transition: background-color 0.12s; |
| bdb18c9 | | | 834 | } |
| bdb18c9 | | | 835 | |
| bdb18c9 | | | 836 | .note-card:last-child { border-bottom: none; } |
| bdb18c9 | | | 837 | |
| bdb18c9 | | | 838 | .note-card:hover { |
| bdb18c9 | | | 839 | background: var(--bg-hover); |
| bdb18c9 | | | 840 | } |
| bdb18c9 | | | 841 | |
| bdb18c9 | | | 842 | .note-card .note-meta { |
| bdb18c9 | | | 843 | display: flex; |
| bdb18c9 | | | 844 | align-items: center; |
| bdb18c9 | | | 845 | gap: 6px; |
| bdb18c9 | | | 846 | margin-bottom: 3px; |
| bdb18c9 | | | 847 | font-size: 10px; |
| bdb18c9 | | | 848 | color: var(--text-muted); |
| bdb18c9 | | | 849 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 850 | } |
| bdb18c9 | | | 851 | |
| bdb18c9 | | | 852 | .note-card .note-author { |
| bdb18c9 | | | 853 | font-weight: 500; |
| bdb18c9 | | | 854 | color: var(--accent); |
| bdb18c9 | | | 855 | } |
| bdb18c9 | | | 856 | |
| bdb18c9 | | | 857 | .note-card .note-text { |
| bdb18c9 | | | 858 | color: var(--text-primary); |
| bdb18c9 | | | 859 | line-height: 1.5; |
| bdb18c9 | | | 860 | font-size: 12px; |
| bdb18c9 | | | 861 | } |
| bdb18c9 | | | 862 | |
| bdb18c9 | | | 863 | .note-card .note-actions { |
| bdb18c9 | | | 864 | position: absolute; |
| bdb18c9 | | | 865 | top: 6px; |
| bdb18c9 | | | 866 | right: 6px; |
| bdb18c9 | | | 867 | display: none; |
| bdb18c9 | | | 868 | gap: 3px; |
| bdb18c9 | | | 869 | } |
| bdb18c9 | | | 870 | |
| bdb18c9 | | | 871 | .note-card:hover .note-actions { display: flex; } |
| bdb18c9 | | | 872 | |
| bdb18c9 | | | 873 | .note-action-btn { |
| bdb18c9 | | | 874 | width: 20px; |
| bdb18c9 | | | 875 | height: 20px; |
| bdb18c9 | | | 876 | border: none; |
| bdb18c9 | | | 877 | background: var(--bg-inset); |
| bdb18c9 | | | 878 | color: var(--text-muted); |
| bdb18c9 | | | 879 | border-radius: 3px; |
| bdb18c9 | | | 880 | cursor: pointer; |
| bdb18c9 | | | 881 | font-size: 10px; |
| bdb18c9 | | | 882 | display: flex; |
| bdb18c9 | | | 883 | align-items: center; |
| bdb18c9 | | | 884 | justify-content: center; |
| bdb18c9 | | | 885 | transition: background-color 0.12s; |
| bdb18c9 | | | 886 | } |
| bdb18c9 | | | 887 | |
| bdb18c9 | | | 888 | .note-action-btn:hover { background: var(--accent); color: var(--accent-text); } |
| bdb18c9 | | | 889 | |
| bdb18c9 | | | 890 | .notes-input-area { |
| bdb18c9 | | | 891 | padding: 8px; |
| bdb18c9 | | | 892 | border-top: 1px solid var(--border-subtle); |
| bdb18c9 | | | 893 | display: flex; |
| bdb18c9 | | | 894 | gap: 6px; |
| bdb18c9 | | | 895 | align-items: flex-end; |
| bdb18c9 | | | 896 | } |
| bdb18c9 | | | 897 | |
| bdb18c9 | | | 898 | .notes-input-area textarea { |
| bdb18c9 | | | 899 | flex: 1; |
| bdb18c9 | | | 900 | background: var(--bg-inset); |
| bdb18c9 | | | 901 | border: 1px solid var(--border-subtle); |
| bdb18c9 | | | 902 | border-radius: 4px; |
| bdb18c9 | | | 903 | color: var(--text-primary); |
| bdb18c9 | | | 904 | padding: 6px 10px; |
| bdb18c9 | | | 905 | font-size: 12px; |
| bdb18c9 | | | 906 | font-family: 'Libre Caslon Text', Georgia, serif; |
| bdb18c9 | | | 907 | resize: none; |
| bdb18c9 | | | 908 | outline: none; |
| bdb18c9 | | | 909 | min-height: 32px; |
| bdb18c9 | | | 910 | max-height: 80px; |
| bdb18c9 | | | 911 | line-height: 1.4; |
| bdb18c9 | | | 912 | transition: border-color 0.12s; |
| bdb18c9 | | | 913 | } |
| bdb18c9 | | | 914 | |
| bdb18c9 | | | 915 | .notes-input-area textarea::placeholder { color: var(--text-faint); font-size: 11px; } |
| bdb18c9 | | | 916 | .notes-input-area textarea:focus { border-color: var(--accent); } |
| bdb18c9 | | | 917 | |
| bdb18c9 | | | 918 | .notes-input-area .note-send-btn { |
| bdb18c9 | | | 919 | width: 32px; |
| bdb18c9 | | | 920 | height: 32px; |
| bdb18c9 | | | 921 | border: none; |
| bdb18c9 | | | 922 | background: var(--accent); |
| bdb18c9 | | | 923 | color: var(--accent-text); |
| bdb18c9 | | | 924 | border-radius: 4px; |
| bdb18c9 | | | 925 | cursor: pointer; |
| bdb18c9 | | | 926 | display: flex; |
| bdb18c9 | | | 927 | align-items: center; |
| bdb18c9 | | | 928 | justify-content: center; |
| bdb18c9 | | | 929 | flex-shrink: 0; |
| bdb18c9 | | | 930 | transition: opacity 0.12s; |
| bdb18c9 | | | 931 | opacity: 0.6; |
| bdb18c9 | | | 932 | } |
| bdb18c9 | | | 933 | |
| bdb18c9 | | | 934 | .notes-input-area .note-send-btn:hover { opacity: 1; } |
| bdb18c9 | | | 935 | |
| bdb18c9 | | | 936 | .notes-input-area .input-hint { |
| bdb18c9 | | | 937 | font-size: 11px; |
| bdb18c9 | | | 938 | color: var(--text-muted); |
| bdb18c9 | | | 939 | margin-top: 4px; |
| bdb18c9 | | | 940 | } |
| bdb18c9 | | | 941 | |
| bdb18c9 | | | 942 | .notes-input-area .input-row { |
| bdb18c9 | | | 943 | display: flex; |
| bdb18c9 | | | 944 | gap: 8px; |
| bdb18c9 | | | 945 | margin-top: 8px; |
| bdb18c9 | | | 946 | align-items: center; |
| bdb18c9 | | | 947 | } |
| bdb18c9 | | | 948 | |
| bdb18c9 | | | 949 | .notes-input-area .click-hint { |
| bdb18c9 | | | 950 | font-size: 11px; |
| bdb18c9 | | | 951 | color: var(--text-faint); |
| bdb18c9 | | | 952 | font-style: italic; |
| bdb18c9 | | | 953 | } |
| bdb18c9 | | | 954 | |
| bdb18c9 | | | 955 | /* Remote cursors */ |
| bdb18c9 | | | 956 | .remote-cursor { |
| bdb18c9 | | | 957 | position: fixed; |
| bdb18c9 | | | 958 | pointer-events: none; |
| bdb18c9 | | | 959 | z-index: 1000; |
| bdb18c9 | | | 960 | transition: left 0.12s linear, top 0.12s linear; |
| bdb18c9 | | | 961 | display: flex; |
| bdb18c9 | | | 962 | align-items: center; |
| bdb18c9 | | | 963 | gap: 4px; |
| bdb18c9 | | | 964 | } |
| bdb18c9 | | | 965 | |
| bdb18c9 | | | 966 | .remote-cursor .cursor-dot { |
| bdb18c9 | | | 967 | width: 10px; |
| bdb18c9 | | | 968 | height: 10px; |
| bdb18c9 | | | 969 | border-radius: 50%; |
| bdb18c9 | | | 970 | border: 1.5px solid rgba(255,255,255,0.8); |
| bdb18c9 | | | 971 | box-shadow: 0 1px 3px rgba(0,0,0,0.3); |
| bdb18c9 | | | 972 | flex-shrink: 0; |
| bdb18c9 | | | 973 | } |
| bdb18c9 | | | 974 | |
| bdb18c9 | | | 975 | .remote-cursor .cursor-label { |
| bdb18c9 | | | 976 | font-size: 9px; |
| bdb18c9 | | | 977 | font-weight: 500; |
| bdb18c9 | | | 978 | color: #fff; |
| bdb18c9 | | | 979 | padding: 1px 5px; |
| bdb18c9 | | | 980 | border-radius: 3px; |
| bdb18c9 | | | 981 | white-space: nowrap; |
| bdb18c9 | | | 982 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 983 | opacity: 0; |
| bdb18c9 | | | 984 | transition: opacity 0.2s; |
| bdb18c9 | | | 985 | } |
| bdb18c9 | | | 986 | |
| bdb18c9 | | | 987 | .remote-cursor:hover .cursor-label, |
| bdb18c9 | | | 988 | .remote-cursor.recent .cursor-label { |
| bdb18c9 | | | 989 | opacity: 1; |
| bdb18c9 | | | 990 | } |
| bdb18c9 | | | 991 | |
| bdb18c9 | | | 992 | |
| bdb18c9 | | | 993 | /* Empty state */ |
| bdb18c9 | | | 994 | .empty-notes { |
| bdb18c9 | | | 995 | display: flex; |
| bdb18c9 | | | 996 | flex-direction: column; |
| bdb18c9 | | | 997 | align-items: center; |
| bdb18c9 | | | 998 | justify-content: center; |
| bdb18c9 | | | 999 | height: 100%; |
| bdb18c9 | | | 1000 | color: var(--text-faint); |
| bdb18c9 | | | 1001 | font-size: 13px; |
| bdb18c9 | | | 1002 | text-align: center; |
| bdb18c9 | | | 1003 | padding: 24px; |
| bdb18c9 | | | 1004 | } |
| bdb18c9 | | | 1005 | |
| bdb18c9 | | | 1006 | .empty-notes .icon { font-size: 32px; margin-bottom: 8px; opacity: 0.4; } |
| bdb18c9 | | | 1007 | |
| bdb18c9 | | | 1008 | /* Toast */ |
| bdb18c9 | | | 1009 | @keyframes toast-enter { |
| bdb18c9 | | | 1010 | from { opacity: 0; transform: translateX(-50%) translateY(8px); } |
| bdb18c9 | | | 1011 | to { opacity: 1; transform: translateX(-50%) translateY(0); } |
| bdb18c9 | | | 1012 | } |
| bdb18c9 | | | 1013 | |
| bdb18c9 | | | 1014 | .toast { |
| bdb18c9 | | | 1015 | position: fixed; |
| bdb18c9 | | | 1016 | bottom: 20px; |
| bdb18c9 | | | 1017 | left: 50%; |
| bdb18c9 | | | 1018 | transform: translateX(-50%); |
| bdb18c9 | | | 1019 | background: var(--bg-card); |
| bdb18c9 | | | 1020 | color: var(--text-primary); |
| bdb18c9 | | | 1021 | border: 1px solid var(--border); |
| bdb18c9 | | | 1022 | padding: 8px 20px; |
| bdb18c9 | | | 1023 | border-radius: 4px; |
| bdb18c9 | | | 1024 | font-size: 13px; |
| bdb18c9 | | | 1025 | z-index: 10000; |
| bdb18c9 | | | 1026 | opacity: 0; |
| bdb18c9 | | | 1027 | transition: opacity 0.3s; |
| bdb18c9 | | | 1028 | pointer-events: none; |
| bdb18c9 | | | 1029 | } |
| bdb18c9 | | | 1030 | |
| bdb18c9 | | | 1031 | .toast.show { opacity: 1; animation: toast-enter 0.15s ease-out; } |
| bdb18c9 | | | 1032 | |
| bdb18c9 | | | 1033 | /* Keyboard hint in drawer footer */ |
| bdb18c9 | | | 1034 | .drawer-footer { |
| bdb18c9 | | | 1035 | padding: 8px 12px; |
| bdb18c9 | | | 1036 | border-top: 1px solid var(--border-subtle); |
| bdb18c9 | | | 1037 | font-size: 10px; |
| bdb18c9 | | | 1038 | color: var(--text-faint); |
| bdb18c9 | | | 1039 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 1040 | text-align: center; |
| bdb18c9 | | | 1041 | flex-shrink: 0; |
| bdb18c9 | | | 1042 | } |
| bdb18c9 | | | 1043 | |
| bdb18c9 | | | 1044 | .drawer-footer kbd { |
| bdb18c9 | | | 1045 | display: inline-block; |
| bdb18c9 | | | 1046 | background: var(--bg-hover); |
| bdb18c9 | | | 1047 | border: 1px solid var(--border); |
| bdb18c9 | | | 1048 | border-radius: 3px; |
| bdb18c9 | | | 1049 | padding: 0 4px; |
| bdb18c9 | | | 1050 | font-size: 10px; |
| bdb18c9 | | | 1051 | font-family: 'JetBrains Mono', Menlo, monospace; |
| bdb18c9 | | | 1052 | } |
| bdb18c9 | | | 1053 | |
| bdb18c9 | | | 1054 | |
| 922dd18 | | | 1055 | /* Homepage */ |
| a3cd4f5 | | | 1056 | .homepage-nav { |
| 922dd18 | | | 1057 | display: flex; |
| a3cd4f5 | | | 1058 | align-items: center; |
| a3cd4f5 | | | 1059 | justify-content: space-between; |
| a3cd4f5 | | | 1060 | height: 3.5rem; |
| a3cd4f5 | | | 1061 | padding: 0 1.5rem; |
| a3cd4f5 | | | 1062 | border-bottom: 1px solid var(--border); |
| a3cd4f5 | | | 1063 | background: var(--bg-page); |
| 922dd18 | | | 1064 | } |
| 922dd18 | | | 1065 | |
| a3cd4f5 | | | 1066 | .homepage-nav .nav-left { |
| 922dd18 | | | 1067 | display: flex; |
| 922dd18 | | | 1068 | align-items: center; |
| a3cd4f5 | | | 1069 | gap: 0.5rem; |
| 922dd18 | | | 1070 | } |
| 922dd18 | | | 1071 | |
| a3cd4f5 | | | 1072 | .homepage-nav .nav-left span { |
| a3cd4f5 | | | 1073 | font-size: 1.125rem; |
| a3cd4f5 | | | 1074 | font-weight: 500; |
| 922dd18 | | | 1075 | color: var(--text-primary); |
| a3cd4f5 | | | 1076 | margin-left: 0.25rem; |
| a3cd4f5 | | | 1077 | } |
| a3cd4f5 | | | 1078 | |
| a3cd4f5 | | | 1079 | .homepage-nav .nav-right { |
| a3cd4f5 | | | 1080 | display: flex; |
| a3cd4f5 | | | 1081 | align-items: center; |
| a3cd4f5 | | | 1082 | gap: 1rem; |
| a3cd4f5 | | | 1083 | font-size: 0.875rem; |
| a3cd4f5 | | | 1084 | } |
| a3cd4f5 | | | 1085 | |
| a3cd4f5 | | | 1086 | .homepage-nav .nav-right a { |
| a3cd4f5 | | | 1087 | color: var(--text-muted); |
| a3cd4f5 | | | 1088 | text-decoration: none; |
| a3cd4f5 | | | 1089 | } |
| a3cd4f5 | | | 1090 | |
| a3cd4f5 | | | 1091 | .homepage-nav .nav-right a:hover { |
| a3cd4f5 | | | 1092 | text-decoration: underline; |
| a3cd4f5 | | | 1093 | } |
| a3cd4f5 | | | 1094 | |
| 7f3fce0 | | | 1095 | .profile-pill { |
| 792cc2e | | | 1096 | display: flex; |
| 792cc2e | | | 1097 | align-items: center; |
| 7f3fce0 | | | 1098 | gap: 6px; |
| 7f3fce0 | | | 1099 | padding: 4px 10px; |
| 7f3fce0 | | | 1100 | border: 1px solid var(--border); |
| 7f3fce0 | | | 1101 | border-radius: 9999px; |
| 7f3fce0 | | | 1102 | background: none; |
| 7f3fce0 | | | 1103 | color: var(--text-muted); |
| 7f3fce0 | | | 1104 | font-size: 0.8125rem; |
| 792cc2e | | | 1105 | font-family: inherit; |
| 7f3fce0 | | | 1106 | cursor: pointer; |
| 7f3fce0 | | | 1107 | } |
| 7f3fce0 | | | 1108 | |
| 7f3fce0 | | | 1109 | .profile-pill:hover { |
| 7f3fce0 | | | 1110 | background: var(--bg-hover); |
| 7f3fce0 | | | 1111 | } |
| 7f3fce0 | | | 1112 | |
| 7f3fce0 | | | 1113 | .profile-pill svg { |
| 7f3fce0 | | | 1114 | flex-shrink: 0; |
| 792cc2e | | | 1115 | } |
| 792cc2e | | | 1116 | |
| 792cc2e | | | 1117 | .profile-menu { |
| 792cc2e | | | 1118 | position: relative; |
| 792cc2e | | | 1119 | } |
| 792cc2e | | | 1120 | |
| 792cc2e | | | 1121 | .profile-dropdown { |
| 792cc2e | | | 1122 | display: none; |
| 792cc2e | | | 1123 | position: absolute; |
| 792cc2e | | | 1124 | right: 0; |
| 792cc2e | | | 1125 | top: calc(100% + 4px); |
| 792cc2e | | | 1126 | min-width: 160px; |
| 792cc2e | | | 1127 | background: var(--bg-card); |
| 792cc2e | | | 1128 | border: 1px solid var(--border); |
| 792cc2e | | | 1129 | box-shadow: 0 4px 12px rgba(0,0,0,0.1); |
| 792cc2e | | | 1130 | z-index: 200; |
| 792cc2e | | | 1131 | } |
| 792cc2e | | | 1132 | |
| 792cc2e | | | 1133 | .profile-dropdown.open { |
| 792cc2e | | | 1134 | display: block; |
| 792cc2e | | | 1135 | } |
| 792cc2e | | | 1136 | |
| 792cc2e | | | 1137 | .profile-dropdown a { |
| 792cc2e | | | 1138 | display: block; |
| 792cc2e | | | 1139 | padding: 6px 12px; |
| 792cc2e | | | 1140 | font-size: 0.875rem; |
| 792cc2e | | | 1141 | color: var(--text-primary); |
| 792cc2e | | | 1142 | text-decoration: none; |
| 792cc2e | | | 1143 | cursor: pointer; |
| 792cc2e | | | 1144 | } |
| 792cc2e | | | 1145 | |
| 792cc2e | | | 1146 | .profile-dropdown a:hover { |
| 792cc2e | | | 1147 | background: var(--bg-hover); |
| 792cc2e | | | 1148 | text-decoration: none; |
| 792cc2e | | | 1149 | } |
| 792cc2e | | | 1150 | |
| a3cd4f5 | | | 1151 | .homepage { |
| a3cd4f5 | | | 1152 | max-width: 48rem; |
| 34cc716 | | | 1153 | width: 100%; |
| a3cd4f5 | | | 1154 | margin: 0 auto; |
| 021201c | | | 1155 | padding: 2rem 1rem; |
| a3cd4f5 | | | 1156 | display: flex; |
| a3cd4f5 | | | 1157 | flex-direction: column; |
| 922dd18 | | | 1158 | } |
| 922dd18 | | | 1159 | |
| 922dd18 | | | 1160 | .homepage-search { |
| 922dd18 | | | 1161 | margin-bottom: 1rem; |
| 922dd18 | | | 1162 | padding: 10px 12px; |
| 922dd18 | | | 1163 | background: var(--bg-card); |
| 922dd18 | | | 1164 | border: 1px solid var(--border-subtle); |
| 922dd18 | | | 1165 | } |
| 922dd18 | | | 1166 | |
| 922dd18 | | | 1167 | .homepage-search input { |
| 922dd18 | | | 1168 | width: 100%; |
| 922dd18 | | | 1169 | font-size: 14px; |
| 922dd18 | | | 1170 | background: transparent; |
| 922dd18 | | | 1171 | color: var(--text-secondary); |
| 922dd18 | | | 1172 | border: none; |
| 922dd18 | | | 1173 | outline: none; |
| 922dd18 | | | 1174 | font-family: 'Libre Caslon Text', Georgia, serif; |
| 922dd18 | | | 1175 | } |
| 922dd18 | | | 1176 | |
| 922dd18 | | | 1177 | .homepage-search input::placeholder { |
| 922dd18 | | | 1178 | color: var(--text-faint); |
| 922dd18 | | | 1179 | } |
| 922dd18 | | | 1180 | |
| 922dd18 | | | 1181 | .homepage-group { |
| 922dd18 | | | 1182 | background: var(--bg-card); |
| 922dd18 | | | 1183 | border: 1px solid var(--border-subtle); |
| 922dd18 | | | 1184 | margin-bottom: 1rem; |
| 922dd18 | | | 1185 | } |
| 922dd18 | | | 1186 | |
| 922dd18 | | | 1187 | .homepage-owner { |
| 922dd18 | | | 1188 | display: block; |
| 922dd18 | | | 1189 | padding: 8px 12px; |
| 922dd18 | | | 1190 | font-size: 14px; |
| 922dd18 | | | 1191 | font-weight: 500; |
| 922dd18 | | | 1192 | color: var(--text-muted); |
| 922dd18 | | | 1193 | border-bottom: 1px solid var(--border-subtle); |
| 922dd18 | | | 1194 | text-decoration: none; |
| 922dd18 | | | 1195 | transition: background-color 0.1s; |
| 922dd18 | | | 1196 | } |
| 922dd18 | | | 1197 | |
| 922dd18 | | | 1198 | .homepage-owner:hover { |
| 922dd18 | | | 1199 | background: var(--bg-hover); |
| 922dd18 | | | 1200 | } |
| 922dd18 | | | 1201 | |
| 922dd18 | | | 1202 | .homepage-repo { |
| 922dd18 | | | 1203 | display: flex; |
| 922dd18 | | | 1204 | align-items: center; |
| 922dd18 | | | 1205 | justify-content: space-between; |
| 922dd18 | | | 1206 | gap: 1rem; |
| 922dd18 | | | 1207 | padding: 10px 12px; |
| 922dd18 | | | 1208 | text-decoration: none; |
| 922dd18 | | | 1209 | transition: background-color 0.1s; |
| 922dd18 | | | 1210 | } |
| 922dd18 | | | 1211 | |
| 922dd18 | | | 1212 | .homepage-repo:hover { |
| 922dd18 | | | 1213 | background: var(--bg-hover); |
| 922dd18 | | | 1214 | } |
| 922dd18 | | | 1215 | |
| 922dd18 | | | 1216 | .homepage-repo + .homepage-repo { |
| 922dd18 | | | 1217 | border-top: 1px solid var(--border-subtle); |
| 922dd18 | | | 1218 | } |
| 922dd18 | | | 1219 | |
| 922dd18 | | | 1220 | .homepage-repo-name { |
| 922dd18 | | | 1221 | color: var(--accent); |
| 922dd18 | | | 1222 | font-size: 14px; |
| 922dd18 | | | 1223 | } |
| 922dd18 | | | 1224 | |
| 922dd18 | | | 1225 | .homepage-repo-desc { |
| 922dd18 | | | 1226 | color: var(--text-faint); |
| 922dd18 | | | 1227 | font-size: 12px; |
| 922dd18 | | | 1228 | margin-top: 2px; |
| 922dd18 | | | 1229 | overflow: hidden; |
| 922dd18 | | | 1230 | text-overflow: ellipsis; |
| 922dd18 | | | 1231 | white-space: nowrap; |
| 922dd18 | | | 1232 | } |
| 922dd18 | | | 1233 | |
| 922dd18 | | | 1234 | .homepage-repo-time { |
| 922dd18 | | | 1235 | color: var(--text-faint); |
| 922dd18 | | | 1236 | font-size: 12px; |
| 922dd18 | | | 1237 | flex-shrink: 0; |
| 922dd18 | | | 1238 | } |
| 922dd18 | | | 1239 | |
| 922dd18 | | | 1240 | .homepage-empty { |
| 922dd18 | | | 1241 | padding: 3rem 1rem; |
| 922dd18 | | | 1242 | text-align: center; |
| 922dd18 | | | 1243 | background: var(--bg-card); |
| 922dd18 | | | 1244 | border: 1px solid var(--border-subtle); |
| 922dd18 | | | 1245 | } |
| 922dd18 | | | 1246 | |
| 922dd18 | | | 1247 | .homepage-empty .logo-wrap { |
| 922dd18 | | | 1248 | opacity: 0.5; |
| 922dd18 | | | 1249 | margin-bottom: 1rem; |
| 922dd18 | | | 1250 | } |
| 922dd18 | | | 1251 | |
| 922dd18 | | | 1252 | .homepage-empty p { |
| 922dd18 | | | 1253 | font-size: 14px; |
| 922dd18 | | | 1254 | color: var(--text-faint); |
| 922dd18 | | | 1255 | } |
| 922dd18 | | | 1256 | |
| 922dd18 | | | 1257 | .homepage-loading { |
| 922dd18 | | | 1258 | background: var(--bg-card); |
| 922dd18 | | | 1259 | border: 1px solid var(--border-subtle); |
| 922dd18 | | | 1260 | } |
| 922dd18 | | | 1261 | |
| 922dd18 | | | 1262 | .homepage-loading .skel-row { |
| 922dd18 | | | 1263 | padding: 10px 12px; |
| 922dd18 | | | 1264 | border-top: 1px solid var(--divide); |
| 922dd18 | | | 1265 | } |
| 922dd18 | | | 1266 | |
| 922dd18 | | | 1267 | .homepage-loading .skel-row:first-child { |
| 922dd18 | | | 1268 | border-top: none; |
| 922dd18 | | | 1269 | } |
| 922dd18 | | | 1270 | |
| 922dd18 | | | 1271 | .skel { |
| 922dd18 | | | 1272 | height: 0.875rem; |
| 922dd18 | | | 1273 | border-radius: 3px; |
| 922dd18 | | | 1274 | background: linear-gradient(90deg, var(--bg-hover) 25%, var(--bg-inset) 50%, var(--bg-hover) 75%); |
| 922dd18 | | | 1275 | background-size: 200% 100%; |
| 922dd18 | | | 1276 | animation: shimmer 1.5s ease-in-out infinite; |
| 922dd18 | | | 1277 | } |
| 922dd18 | | | 1278 | |
| 922dd18 | | | 1279 | @keyframes shimmer { |
| 922dd18 | | | 1280 | 0% { background-position: 200% 0; } |
| 922dd18 | | | 1281 | 100% { background-position: -200% 0; } |
| 922dd18 | | | 1282 | } |
| 922dd18 | | | 1283 | |
| bdb18c9 | | | 1284 | </style> |
| bdb18c9 | | | 1285 | </head> |
| bdb18c9 | | | 1286 | <body> |
| bdb18c9 | | | 1287 | |
| bdb18c9 | | | 1288 | <!-- Top bar --> |
| bdb18c9 | | | 1289 | <div class="topbar"> |
| e629dcb | | | 1290 | <div class="topbar-left"> |
| e629dcb | | | 1291 | <a href="/" class="logo"> |
| bf6031c | | | 1292 | <svg width="28" height="28" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="var(--accent)"/><path d="M9 16 C9 11, 14 8, 21 8 L35 8 C42 8, 47 11, 47 16 L47 24 C47 29, 42 32, 35 32 L19 32 L13 37 L13 32 C10 31, 9 29, 9 24 Z" fill="white"/><path d="M55 35 C55 30, 50 27, 43 27 L29 27 C22 27, 17 30, 17 35 L17 43 C17 48, 22 51, 29 51 L45 51 L51 56 L51 51 C54 50, 55 48, 55 43 Z" fill="white" opacity="0.65"/></svg> |
| e629dcb | | | 1293 | </a> |
| e629dcb | | | 1294 | <span class="breadcrumb-sep">/</span> |
| e629dcb | | | 1295 | <a id="breadcrumbOwner" class="breadcrumb-item" href="#">—</a> |
| e629dcb | | | 1296 | <span class="breadcrumb-sep">/</span> |
| e629dcb | | | 1297 | <span id="breadcrumbRepo" class="breadcrumb-current">—</span> |
| bdb18c9 | | | 1298 | </div> |
| e629dcb | | | 1299 | <div class="topbar-right"> |
| e629dcb | | | 1300 | <div class="presence" id="presenceBar"></div> |
| e629dcb | | | 1301 | <div class="actions"> |
| e629dcb | | | 1302 | <button class="btn" onclick="copyRoomLink()" title="Copy shareable link">Copy Link</button> |
| e629dcb | | | 1303 | <button class="btn" onclick="exportNotesLLM()" title="Export notes in LLM-readable format">Export</button> |
| e629dcb | | | 1304 | </div> |
| bf6031c | | | 1305 | <a href="/login" id="topbarSignIn" class="sign-in-link" style="color:var(--text-muted);font-size:0.875rem;text-decoration:none">Sign in</a> |
| e629dcb | | | 1306 | <div class="profile-menu" id="topbarProfileMenu" style="display:none"> |
| 7f3fce0 | | | 1307 | <button class="profile-pill" id="topbarProfileToggle"> |
| 7f3fce0 | | | 1308 | <span id="topbarProfileName"></span> |
| 7f3fce0 | | | 1309 | <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4L5 6.5L7.5 4" /></svg> |
| 7f3fce0 | | | 1310 | </button> |
| e629dcb | | | 1311 | <div class="profile-dropdown" id="topbarProfileDropdown"> |
| e629dcb | | | 1312 | <a href="/dashboard">Dashboard</a> |
| e629dcb | | | 1313 | <a href="#" id="topbarProfileSignOut">Sign out</a> |
| e629dcb | | | 1314 | </div> |
| e629dcb | | | 1315 | </div> |
| bdb18c9 | | | 1316 | </div> |
| bdb18c9 | | | 1317 | </div> |
| bdb18c9 | | | 1318 | |
| bdb18c9 | | | 1319 | <!-- Main content --> |
| bdb18c9 | | | 1320 | <div class="main"> |
| bdb18c9 | | | 1321 | <!-- Left drawer --> |
| bdb18c9 | | | 1322 | <div class="drawer" id="drawer"> |
| bdb18c9 | | | 1323 | <div class="drawer-header">Diagrams</div> |
| bdb18c9 | | | 1324 | <div class="drawer-list" id="drawerList"></div> |
| bdb18c9 | | | 1325 | <div class="drawer-footer"><kbd>↑</kbd> <kbd>↓</kbd> to navigate</div> |
| bdb18c9 | | | 1326 | </div> |
| bdb18c9 | | | 1327 | |
| bdb18c9 | | | 1328 | <!-- Code pane (left) --> |
| bdb18c9 | | | 1329 | <div class="code-pane" id="codePane"> |
| bdb18c9 | | | 1330 | <div class="code-pane-header"> |
| bdb18c9 | | | 1331 | <span onclick="toggleCodePane()" style="display:flex;align-items:center;gap:6px;flex:1;cursor:pointer"> |
| bdb18c9 | | | 1332 | <span class="code-pane-toggle" id="codePaneToggle">▶</span> |
| bdb18c9 | | | 1333 | <span>Definition</span> |
| bdb18c9 | | | 1334 | </span> |
| bdb18c9 | | | 1335 | <span class="code-pane-text-size" style="display:none"> |
| bdb18c9 | | | 1336 | <button class="code-pane-fmt-btn" style="display:inline-block;padding:2px 5px" onclick="changeEditorFontSize(-1)" title="Decrease font size">A−</button> |
| bdb18c9 | | | 1337 | <button class="code-pane-fmt-btn" style="display:inline-block;padding:2px 5px" onclick="changeEditorFontSize(1)" title="Increase font size">A+</button> |
| bdb18c9 | | | 1338 | </span> |
| bdb18c9 | | | 1339 | <span class="code-pane-status" id="codePaneStatus"></span> |
| bdb18c9 | | | 1340 | <button class="code-pane-fmt-btn" onclick="formatMermaid()" title="Format (Shift+Alt+F)">Format</button> |
| bdb18c9 | | | 1341 | <button class="code-pane-play-btn" id="codePanePlay" onclick="commitDiagramCode()" title="Render diagram (Ctrl+Enter)">▶ Run</button> |
| bdb18c9 | | | 1342 | </div> |
| bdb18c9 | | | 1343 | <div class="code-pane-editor" id="codePaneEditor"></div> |
| bdb18c9 | | | 1344 | <div class="code-pane-diagnostics" id="codePaneDiagnostics"></div> |
| bdb18c9 | | | 1345 | <div class="code-pane-resize" id="codePaneResize"></div> |
| bdb18c9 | | | 1346 | </div> |
| bdb18c9 | | | 1347 | |
| bdb18c9 | | | 1348 | <!-- Diagram viewer --> |
| bdb18c9 | | | 1349 | <div class="diagram-pane" id="diagramPane"> |
| bdb18c9 | | | 1350 | <div class="mermaid-container" id="mermaidContainer"></div> |
| bdb18c9 | | | 1351 | <div class="bottom-toolbar"> |
| bdb18c9 | | | 1352 | <!-- Zoom controls --> |
| bdb18c9 | | | 1353 | <button class="zoom-btn" onclick="zoomOut()" title="Zoom out">−</button> |
| bdb18c9 | | | 1354 | <span class="zoom-level" id="zoomLevel">100%</span> |
| bdb18c9 | | | 1355 | <button class="zoom-btn" onclick="zoomIn()" title="Zoom in">+</button> |
| bdb18c9 | | | 1356 | <button class="zoom-btn" onclick="zoomReset()" title="Fit to view">↺</button> |
| bdb18c9 | | | 1357 | </div> |
| bdb18c9 | | | 1358 | </div> |
| bdb18c9 | | | 1359 | |
| bdb18c9 | | | 1360 | <!-- Notes sidebar --> |
| bdb18c9 | | | 1361 | <div class="notes-sidebar" id="notesSidebar"> |
| bdb18c9 | | | 1362 | <div class="notes-resize" id="notesResize"></div> |
| bdb18c9 | | | 1363 | <div class="notes-header"> |
| bdb18c9 | | | 1364 | <span id="notesTitle">Notes</span> |
| bdb18c9 | | | 1365 | <span id="notesCount"></span> |
| bdb18c9 | | | 1366 | </div> |
| bdb18c9 | | | 1367 | <div class="notes-list" id="notesList"></div> |
| bdb18c9 | | | 1368 | <div class="notes-input-area"> |
| bdb18c9 | | | 1369 | <textarea id="noteInput" placeholder="Add a note..." rows="1"></textarea> |
| bdb18c9 | | | 1370 | <button class="note-send-btn" onclick="submitNote()" title="Send note (Enter)"> |
| bdb18c9 | | | 1371 | <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> |
| bdb18c9 | | | 1372 | </button> |
| bdb18c9 | | | 1373 | </div> |
| bdb18c9 | | | 1374 | </div> |
| bdb18c9 | | | 1375 | </div> |
| bdb18c9 | | | 1376 | |
| bdb18c9 | | | 1377 | <div class="toast" id="toast"></div> |
| bdb18c9 | | | 1378 | |
| bdb18c9 | | | 1379 | <script> |
| 1e755c0 | | | 1380 | // ── Diagram definitions (loaded from server) ── |
| 1e755c0 | | | 1381 | let DIAGRAM_SECTIONS = []; |
| 1e755c0 | | | 1382 | let DIAGRAMS = []; |
| 1e755c0 | | | 1383 | |
| 2a9592c | | | 1384 | async function loadDiagramsFromServer(owner, repo) { |
| 1e755c0 | | | 1385 | try { |
| 2a9592c | | | 1386 | const headers = {}; |
| 2a9592c | | | 1387 | const token = getCookie("grove_hub_token"); |
| 2a9592c | | | 1388 | if (token) headers["Authorization"] = `Bearer ${token}`; |
| 2a9592c | | | 1389 | const res = await fetch(`/api/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/diagrams`, { headers }); |
| 1e755c0 | | | 1390 | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 1e755c0 | | | 1391 | const data = await res.json(); |
| 1e755c0 | | | 1392 | DIAGRAM_SECTIONS = data.sections || []; |
| 1e755c0 | | | 1393 | DIAGRAMS = data.diagrams || []; |
| 1e755c0 | | | 1394 | } catch (e) { |
| 1e755c0 | | | 1395 | console.error("[diagrams] Failed to load from server:", e); |
| 1e755c0 | | | 1396 | DIAGRAM_SECTIONS = [{ label: "Error" }]; |
| 1e755c0 | | | 1397 | DIAGRAMS = [{ |
| 1e755c0 | | | 1398 | id: "error", |
| 1e755c0 | | | 1399 | title: "Failed to load diagrams", |
| 1e755c0 | | | 1400 | section: 0, |
| 1e755c0 | | | 1401 | code: "graph TD\n A[Error loading diagrams from server]" |
| 1e755c0 | | | 1402 | }]; |
| bdb18c9 | | | 1403 | } |
| 1e755c0 | | | 1404 | } |
| 1e755c0 | | | 1405 | |
| 1e755c0 | | | 1406 | // Diagram seed data lives in diagrams-default.json on the server. |
| 1e755c0 | | | 1407 | // DIAGRAMS and DIAGRAM_SECTIONS are populated by loadDiagramsFromServer() above. |
| bdb18c9 | | | 1408 | |
| 2a9592c | | | 1409 | // ── Auth helpers ── |
| 2a9592c | | | 1410 | function getCookie(name) { |
| 2a9592c | | | 1411 | const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); |
| 2a9592c | | | 1412 | return match ? decodeURIComponent(match[1]) : null; |
| 2a9592c | | | 1413 | } |
| 2a9592c | | | 1414 | |
| bdb18c9 | | | 1415 | // ── State ── |
| bdb18c9 | | | 1416 | let socket = null; |
| bdb18c9 | | | 1417 | let currentTab = 0; |
| bdb18c9 | | | 1418 | let myName = ""; |
| bdb18c9 | | | 1419 | let roomId = ""; |
| 2a9592c | | | 1420 | let currentOwner = ""; |
| 2a9592c | | | 1421 | let currentRepo = ""; |
| bdb18c9 | | | 1422 | let allNotes = {}; // { diagramId: [note, ...] } |
| bdb18c9 | | | 1423 | let allUsers = {}; |
| bdb18c9 | | | 1424 | let remoteCursors = {}; // { socketId: DOM element } |
| bdb18c9 | | | 1425 | |
| bdb18c9 | | | 1426 | // Pan/zoom state |
| bdb18c9 | | | 1427 | let panZoom = { x: 0, y: 0, scale: 1 }; |
| bdb18c9 | | | 1428 | let isPanning = false; |
| bdb18c9 | | | 1429 | let panStart = { x: 0, y: 0 }; |
| bdb18c9 | | | 1430 | let panStartOffset = { x: 0, y: 0 }; |
| cee484a | | | 1431 | let didDrag = false; // true if mouse moved during a pan gesture (suppress click) |
| cee484a | | | 1432 | let selectedNodeId = null; // currently focused node for dim effect |
| bdb18c9 | | | 1433 | |
| bdb18c9 | | | 1434 | // ── Init ── |
| bdb18c9 | | | 1435 | mermaid.initialize({ |
| bdb18c9 | | | 1436 | startOnLoad: false, |
| bdb18c9 | | | 1437 | theme: "base", |
| bdb18c9 | | | 1438 | themeVariables: { |
| bdb18c9 | | | 1439 | primaryColor: "#e8f2ee", |
| bdb18c9 | | | 1440 | primaryTextColor: "#2c2824", |
| bdb18c9 | | | 1441 | primaryBorderColor: "#4d8a78", |
| bdb18c9 | | | 1442 | lineColor: "#4d8a78", |
| bdb18c9 | | | 1443 | secondaryColor: "#f2efe9", |
| bdb18c9 | | | 1444 | tertiaryColor: "#eae6df", |
| bdb18c9 | | | 1445 | noteBkgColor: "#e8f2ee", |
| bdb18c9 | | | 1446 | noteTextColor: "#2d6b56", |
| bdb18c9 | | | 1447 | noteBorderColor: "#b0d4c5", |
| bdb18c9 | | | 1448 | textColor: "#2c2824", |
| bdb18c9 | | | 1449 | mainBkg: "#f2efe9", |
| bdb18c9 | | | 1450 | nodeBorder: "#d9d3ca", |
| bdb18c9 | | | 1451 | clusterBkg: "#faf8f5", |
| bdb18c9 | | | 1452 | clusterBorder: "#d9d3ca", |
| bdb18c9 | | | 1453 | titleColor: "#2c2824", |
| bdb18c9 | | | 1454 | edgeLabelBackground: "#faf8f5", |
| bdb18c9 | | | 1455 | actorBkg: "#f2efe9", |
| bdb18c9 | | | 1456 | actorBorder: "#4d8a78", |
| bdb18c9 | | | 1457 | actorTextColor: "#2c2824", |
| bdb18c9 | | | 1458 | labelColor: "#2c2824", |
| bdb18c9 | | | 1459 | loopTextColor: "#4a4540", |
| bdb18c9 | | | 1460 | activationBorderColor: "#4d8a78", |
| bdb18c9 | | | 1461 | activationBkgColor: "#e8f2ee", |
| bdb18c9 | | | 1462 | sequenceNumberColor: "#ffffff", |
| bdb18c9 | | | 1463 | background: "#faf8f5", |
| bdb18c9 | | | 1464 | fontFamily: "'Libre Caslon Text', Georgia, serif", |
| bdb18c9 | | | 1465 | } |
| bdb18c9 | | | 1466 | }); |
| bdb18c9 | | | 1467 | |
| bdb18c9 | | | 1468 | |
| bdb18c9 | | | 1469 | // ── Drawer ── |
| bdb18c9 | | | 1470 | |
| bdb18c9 | | | 1471 | function buildDrawer() { |
| bdb18c9 | | | 1472 | const list = document.getElementById("drawerList"); |
| bdb18c9 | | | 1473 | list.innerHTML = ""; |
| bdb18c9 | | | 1474 | |
| bdb18c9 | | | 1475 | let lastSection = -1; |
| bdb18c9 | | | 1476 | |
| bdb18c9 | | | 1477 | DIAGRAMS.forEach((d, i) => { |
| bdb18c9 | | | 1478 | // Section header |
| bdb18c9 | | | 1479 | if (d.section !== lastSection) { |
| bdb18c9 | | | 1480 | lastSection = d.section; |
| bdb18c9 | | | 1481 | const sec = document.createElement("div"); |
| bdb18c9 | | | 1482 | sec.className = "drawer-section"; |
| bdb18c9 | | | 1483 | sec.textContent = DIAGRAM_SECTIONS[d.section].label; |
| bdb18c9 | | | 1484 | list.appendChild(sec); |
| bdb18c9 | | | 1485 | } |
| bdb18c9 | | | 1486 | |
| bdb18c9 | | | 1487 | const item = document.createElement("div"); |
| bdb18c9 | | | 1488 | item.className = "drawer-item" + (i === currentTab ? " active" : ""); |
| bdb18c9 | | | 1489 | item.dataset.index = i; |
| bdb18c9 | | | 1490 | item.onclick = () => switchTab(i); |
| bdb18c9 | | | 1491 | |
| bdb18c9 | | | 1492 | let html = `<span class="item-label">${escapeHtml(d.title)}</span>`; |
| bdb18c9 | | | 1493 | |
| bdb18c9 | | | 1494 | const count = (allNotes[d.id] || []).length; |
| bdb18c9 | | | 1495 | if (count > 0) html += `<span class="note-count">${count}</span>`; |
| bdb18c9 | | | 1496 | |
| bdb18c9 | | | 1497 | // Show dots for users viewing this tab |
| bdb18c9 | | | 1498 | const viewers = Object.values(allUsers).filter(u => u.activeTab === d.id && u.id !== socket?.id); |
| bdb18c9 | | | 1499 | if (viewers.length > 0) { |
| bdb18c9 | | | 1500 | html += '<span class="viewer-dots">'; |
| bdb18c9 | | | 1501 | viewers.forEach(v => { |
| bdb18c9 | | | 1502 | html += `<span class="viewer-dot" style="background:${v.color}" title="${escapeHtml(v.name)}"></span>`; |
| bdb18c9 | | | 1503 | }); |
| bdb18c9 | | | 1504 | html += '</span>'; |
| bdb18c9 | | | 1505 | } |
| bdb18c9 | | | 1506 | |
| bdb18c9 | | | 1507 | item.innerHTML = html; |
| bdb18c9 | | | 1508 | list.appendChild(item); |
| bdb18c9 | | | 1509 | }); |
| bdb18c9 | | | 1510 | } |
| bdb18c9 | | | 1511 | |
| bdb18c9 | | | 1512 | async function renderDiagram(index) { |
| bdb18c9 | | | 1513 | const container = document.getElementById("mermaidContainer"); |
| bdb18c9 | | | 1514 | container.innerHTML = ""; |
| bdb18c9 | | | 1515 | const diagram = DIAGRAMS[index]; |
| bdb18c9 | | | 1516 | |
| bdb18c9 | | | 1517 | const div = document.createElement("div"); |
| bdb18c9 | | | 1518 | div.id = `mermaid-${index}`; |
| bdb18c9 | | | 1519 | container.appendChild(div); |
| bdb18c9 | | | 1520 | |
| bdb18c9 | | | 1521 | try { |
| bdb18c9 | | | 1522 | const { svg } = await mermaid.render(`mermaid-svg-${index}-${Date.now()}`, diagram.code); |
| bdb18c9 | | | 1523 | div.innerHTML = svg; |
| bdb18c9 | | | 1524 | |
| bdb18c9 | | | 1525 | // Resize SVG so its natural size fills the pane at 100% zoom |
| bdb18c9 | | | 1526 | const pane = document.getElementById("diagramPane"); |
| bdb18c9 | | | 1527 | const svgEl = div.querySelector("svg"); |
| bdb18c9 | | | 1528 | if (svgEl) { |
| bdb18c9 | | | 1529 | const pw = pane.clientWidth - 80; // account for container padding |
| bdb18c9 | | | 1530 | const ph = pane.clientHeight - 80; |
| bdb18c9 | | | 1531 | const vb = svgEl.viewBox.baseVal; |
| bdb18c9 | | | 1532 | const svgW = vb.width || svgEl.width.baseVal.value || parseFloat(svgEl.getAttribute("width")); |
| bdb18c9 | | | 1533 | const svgH = vb.height || svgEl.height.baseVal.value || parseFloat(svgEl.getAttribute("height")); |
| bdb18c9 | | | 1534 | if (svgW && svgH) { |
| bdb18c9 | | | 1535 | const fitScale = Math.min(pw / svgW, ph / svgH); |
| bdb18c9 | | | 1536 | const newW = svgW * fitScale; |
| bdb18c9 | | | 1537 | const newH = svgH * fitScale; |
| bdb18c9 | | | 1538 | svgEl.setAttribute("width", newW); |
| bdb18c9 | | | 1539 | svgEl.setAttribute("height", newH); |
| bdb18c9 | | | 1540 | svgEl.style.width = newW + "px"; |
| bdb18c9 | | | 1541 | svgEl.style.height = newH + "px"; |
| bdb18c9 | | | 1542 | } |
| bdb18c9 | | | 1543 | } |
| cee484a | | | 1544 | // Wire up node-focus click handlers |
| 38b80fd | | | 1545 | wireNodeFocus(div); |
| bdb18c9 | | | 1546 | } catch (e) { |
| bdb18c9 | | | 1547 | div.innerHTML = `<pre style="color:#a05050;padding:20px;font-family:'JetBrains Mono',Menlo,monospace;font-size:13px;">Error rendering diagram:\n${e.message}</pre>`; |
| bdb18c9 | | | 1548 | } |
| bdb18c9 | | | 1549 | |
| bdb18c9 | | | 1550 | } |
| bdb18c9 | | | 1551 | |
| cee484a | | | 1552 | // ── Node Focus (dim unconnected elements on click) ── |
| cee484a | | | 1553 | |
| cee484a | | | 1554 | /** |
| 38b80fd | | | 1555 | * Build adjacency and node map directly from the rendered SVG. |
| 38b80fd | | | 1556 | * Uses data-start/data-end on edges (from our patched Mermaid) and |
| 38b80fd | | | 1557 | * the g.id on node groups — everything uses the same Mermaid internal IDs. |
| cee484a | | | 1558 | */ |
| 38b80fd | | | 1559 | function buildGraphFromSvg(svgEl) { |
| cee484a | | | 1560 | const adjacency = {}; |
| 38b80fd | | | 1561 | const nodeMap = new Map(); |
| cee484a | | | 1562 | |
| cee484a | | | 1563 | const ensure = (id) => { if (!adjacency[id]) adjacency[id] = new Set(); }; |
| cee484a | | | 1564 | const addNode = (id, el) => { |
| cee484a | | | 1565 | if (!nodeMap.has(id)) nodeMap.set(id, []); |
| cee484a | | | 1566 | nodeMap.get(id).push(el); |
| cee484a | | | 1567 | el.classList.add("node-dimable", "node-clickable"); |
| cee484a | | | 1568 | }; |
| cee484a | | | 1569 | |
| 38b80fd | | | 1570 | // Collect nodes — extract the short ID that edges use in data-start/data-end. |
| 38b80fd | | | 1571 | // Flowchart: g.id "flowchart-Customer-0" → edge uses "Customer" |
| 38b80fd | | | 1572 | // ER: g.id "entity-policy-3" → edge uses "entity-policy-3" |
| cee484a | | | 1573 | svgEl.querySelectorAll("g.node").forEach(g => { |
| 38b80fd | | | 1574 | if (!g.id) return; |
| 38b80fd | | | 1575 | const parts = g.id.split("-"); |
| 38b80fd | | | 1576 | const short = parts.length >= 3 ? parts.slice(1, -1).join("-") : g.id; |
| 38b80fd | | | 1577 | addNode(short, g); |
| cee484a | | | 1578 | }); |
| 38b80fd | | | 1579 | svgEl.querySelectorAll("g[id^='entity-'], g.classGroup, g.statediagram-state").forEach(g => { |
| 38b80fd | | | 1580 | if (g.id) addNode(g.id, g); |
| cee484a | | | 1581 | }); |
| cee484a | | | 1582 | |
| 38b80fd | | | 1583 | // Build adjacency from data-start / data-end on edges |
| 38b80fd | | | 1584 | svgEl.querySelectorAll("[data-start][data-end]").forEach(el => { |
| 38b80fd | | | 1585 | const s = el.getAttribute("data-start"); |
| 38b80fd | | | 1586 | const e = el.getAttribute("data-end"); |
| 38b80fd | | | 1587 | if (s && e) { |
| 38b80fd | | | 1588 | ensure(s); ensure(e); |
| 38b80fd | | | 1589 | adjacency[s].add(e); |
| 38b80fd | | | 1590 | adjacency[e].add(s); |
| 38b80fd | | | 1591 | } |
| 38b80fd | | | 1592 | el.classList.add("node-dimable"); |
| cee484a | | | 1593 | }); |
| cee484a | | | 1594 | |
| 38b80fd | | | 1595 | // Tag edge containers and clusters as dimable |
| cee484a | | | 1596 | svgEl.querySelectorAll(".edgePath, .edgeLabel, .cluster").forEach(el => { |
| cee484a | | | 1597 | el.classList.add("node-dimable"); |
| cee484a | | | 1598 | }); |
| cee484a | | | 1599 | |
| 38b80fd | | | 1600 | // Tag loose path/line elements NOT inside a node, existing dimable, or marker defs |
| 38b80fd | | | 1601 | svgEl.querySelectorAll("path, line").forEach(el => { |
| 38b80fd | | | 1602 | if (el.closest("marker, defs")) return; // skip arrowhead marker definitions |
| 38b80fd | | | 1603 | if (!el.closest(".node-dimable") && !el.closest(".node-clickable")) { |
| cee484a | | | 1604 | el.classList.add("node-dimable"); |
| 38b80fd | | | 1605 | } |
| 38b80fd | | | 1606 | }); |
| cee484a | | | 1607 | |
| 38b80fd | | | 1608 | // Match edge labels to edge paths using data-id → path ID. |
| 38b80fd | | | 1609 | // Edge paths have id="L_Start_End_0" and data-start/data-end. |
| 38b80fd | | | 1610 | // Edge labels contain g.label[data-id] where data-id matches path id. |
| 38b80fd | | | 1611 | // Copy data-start/data-end onto the edgeLabel group so highlighting works. |
| 38b80fd | | | 1612 | const pathDataById = new Map(); |
| 38b80fd | | | 1613 | svgEl.querySelectorAll("[data-start][data-end]").forEach(el => { |
| 38b80fd | | | 1614 | if (el.id) pathDataById.set(el.id, { start: el.getAttribute("data-start"), end: el.getAttribute("data-end") }); |
| 38b80fd | | | 1615 | }); |
| 38b80fd | | | 1616 | svgEl.querySelectorAll("g.edgeLabel").forEach(label => { |
| 38b80fd | | | 1617 | const inner = label.querySelector("[data-id]"); |
| 38b80fd | | | 1618 | const dataId = inner?.getAttribute("data-id"); |
| 38b80fd | | | 1619 | if (dataId && pathDataById.has(dataId)) { |
| 38b80fd | | | 1620 | const { start, end } = pathDataById.get(dataId); |
| 38b80fd | | | 1621 | label.setAttribute("data-start", start); |
| 38b80fd | | | 1622 | label.setAttribute("data-end", end); |
| 38b80fd | | | 1623 | } |
| 38b80fd | | | 1624 | }); |
| cee484a | | | 1625 | |
| 38b80fd | | | 1626 | return { adjacency, nodeMap }; |
| cee484a | | | 1627 | } |
| cee484a | | | 1628 | |
| cee484a | | | 1629 | function clearNodeFocus() { |
| cee484a | | | 1630 | selectedNodeId = null; |
| cee484a | | | 1631 | const pane = document.getElementById("diagramPane"); |
| cee484a | | | 1632 | pane.classList.remove("node-focus"); |
| cee484a | | | 1633 | const svg = pane.querySelector("svg"); |
| cee484a | | | 1634 | if (svg) { |
| cee484a | | | 1635 | svg.querySelectorAll(".node-highlight").forEach(el => el.classList.remove("node-highlight")); |
| cee484a | | | 1636 | } |
| cee484a | | | 1637 | } |
| cee484a | | | 1638 | |
| cee484a | | | 1639 | function applyNodeFocus(svgEl, clickedNodeId, adjacency, nodeMap) { |
| cee484a | | | 1640 | const pane = document.getElementById("diagramPane"); |
| cee484a | | | 1641 | |
| cee484a | | | 1642 | // Connected set = clicked + immediate neighbours |
| cee484a | | | 1643 | const connected = new Set([clickedNodeId]); |
| cee484a | | | 1644 | if (adjacency[clickedNodeId]) adjacency[clickedNodeId].forEach(id => connected.add(id)); |
| cee484a | | | 1645 | |
| cee484a | | | 1646 | // Clear old highlights |
| cee484a | | | 1647 | svgEl.querySelectorAll(".node-highlight").forEach(el => el.classList.remove("node-highlight")); |
| cee484a | | | 1648 | |
| 38b80fd | | | 1649 | // Helper: highlight an element and all its node-dimable descendants |
| 38b80fd | | | 1650 | // (prevents CSS opacity compounding from dimming children) |
| 38b80fd | | | 1651 | const highlight = (el) => { |
| 38b80fd | | | 1652 | el.classList.add("node-highlight"); |
| 38b80fd | | | 1653 | el.querySelectorAll(".node-dimable").forEach(child => child.classList.add("node-highlight")); |
| 38b80fd | | | 1654 | }; |
| 38b80fd | | | 1655 | |
| cee484a | | | 1656 | // Highlight connected nodes |
| cee484a | | | 1657 | for (const [id, elements] of nodeMap) { |
| 38b80fd | | | 1658 | if (connected.has(id)) elements.forEach(highlight); |
| cee484a | | | 1659 | } |
| cee484a | | | 1660 | |
| 38b80fd | | | 1661 | // Highlight all elements with data-start/data-end where both endpoints are connected |
| 38b80fd | | | 1662 | svgEl.querySelectorAll("[data-start][data-end]").forEach(el => { |
| 38b80fd | | | 1663 | const s = el.getAttribute("data-start"); |
| 38b80fd | | | 1664 | const e = el.getAttribute("data-end"); |
| 38b80fd | | | 1665 | if (connected.has(s) && connected.has(e)) highlight(el); |
| cee484a | | | 1666 | }); |
| cee484a | | | 1667 | |
| cee484a | | | 1668 | pane.classList.add("node-focus"); |
| cee484a | | | 1669 | selectedNodeId = clickedNodeId; |
| cee484a | | | 1670 | } |
| cee484a | | | 1671 | |
| cee484a | | | 1672 | /** |
| cee484a | | | 1673 | * Attach click handlers to all nodes in the rendered SVG. |
| cee484a | | | 1674 | */ |
| 38b80fd | | | 1675 | function wireNodeFocus(container) { |
| cee484a | | | 1676 | const svgEl = container.querySelector("svg"); |
| cee484a | | | 1677 | if (!svgEl) return; |
| cee484a | | | 1678 | |
| 38b80fd | | | 1679 | const { adjacency, nodeMap } = buildGraphFromSvg(svgEl); |
| 38b80fd | | | 1680 | if (Object.keys(adjacency).length === 0) return; |
| cee484a | | | 1681 | |
| 38b80fd | | | 1682 | for (const [nodeId, elements] of nodeMap) { |
| cee484a | | | 1683 | elements.forEach(el => { |
| cee484a | | | 1684 | el.addEventListener("click", (e) => { |
| cee484a | | | 1685 | e.stopPropagation(); |
| cee484a | | | 1686 | if (didDrag) return; |
| 38b80fd | | | 1687 | if (selectedNodeId === nodeId) { |
| cee484a | | | 1688 | clearNodeFocus(); |
| cee484a | | | 1689 | } else { |
| 38b80fd | | | 1690 | applyNodeFocus(svgEl, nodeId, adjacency, nodeMap); |
| cee484a | | | 1691 | } |
| cee484a | | | 1692 | }); |
| cee484a | | | 1693 | }); |
| cee484a | | | 1694 | } |
| cee484a | | | 1695 | } |
| cee484a | | | 1696 | |
| bdb18c9 | | | 1697 | function renderNotesList(diagramId) { |
| bdb18c9 | | | 1698 | const list = document.getElementById("notesList"); |
| bdb18c9 | | | 1699 | const notes = allNotes[diagramId] || []; |
| bdb18c9 | | | 1700 | const countEl = document.getElementById("notesCount"); |
| bdb18c9 | | | 1701 | countEl.textContent = notes.length > 0 ? `(${notes.length})` : ""; |
| bdb18c9 | | | 1702 | |
| bdb18c9 | | | 1703 | if (notes.length === 0) { |
| bdb18c9 | | | 1704 | list.innerHTML = `<div class="empty-notes"> |
| bdb18c9 | | | 1705 | No notes yet for this diagram.<br>Click on the diagram or type below to add one. |
| bdb18c9 | | | 1706 | </div>`; |
| bdb18c9 | | | 1707 | return; |
| bdb18c9 | | | 1708 | } |
| bdb18c9 | | | 1709 | |
| bdb18c9 | | | 1710 | list.innerHTML = ""; |
| be59e71 | | | 1711 | notes.forEach((note) => { |
| bdb18c9 | | | 1712 | const card = document.createElement("div"); |
| bdb18c9 | | | 1713 | card.className = "note-card"; |
| bdb18c9 | | | 1714 | card.id = `note-${note.id}`; |
| be59e71 | | | 1715 | |
| be59e71 | | | 1716 | const meta = document.createElement("div"); |
| be59e71 | | | 1717 | meta.className = "note-meta"; |
| be59e71 | | | 1718 | const authorSpan = document.createElement("span"); |
| be59e71 | | | 1719 | authorSpan.className = "note-author"; |
| be59e71 | | | 1720 | authorSpan.textContent = note.author; |
| be59e71 | | | 1721 | const timeSpan = document.createElement("span"); |
| be59e71 | | | 1722 | timeSpan.textContent = formatTime(note.timestamp); |
| be59e71 | | | 1723 | meta.appendChild(authorSpan); |
| be59e71 | | | 1724 | meta.appendChild(timeSpan); |
| be59e71 | | | 1725 | |
| be59e71 | | | 1726 | const textEl = document.createElement("div"); |
| be59e71 | | | 1727 | textEl.className = "note-text"; |
| be59e71 | | | 1728 | textEl.textContent = note.text; |
| be59e71 | | | 1729 | |
| be59e71 | | | 1730 | const actions = document.createElement("div"); |
| be59e71 | | | 1731 | actions.className = "note-actions"; |
| be59e71 | | | 1732 | |
| be59e71 | | | 1733 | const editBtn = document.createElement("button"); |
| be59e71 | | | 1734 | editBtn.className = "note-action-btn"; |
| be59e71 | | | 1735 | editBtn.title = "Edit"; |
| be59e71 | | | 1736 | editBtn.innerHTML = "✎"; |
| be59e71 | | | 1737 | editBtn.addEventListener("click", () => editNote(note.id, note.diagramId, card)); |
| be59e71 | | | 1738 | |
| be59e71 | | | 1739 | const deleteBtn = document.createElement("button"); |
| be59e71 | | | 1740 | deleteBtn.className = "note-action-btn"; |
| be59e71 | | | 1741 | deleteBtn.title = "Delete"; |
| be59e71 | | | 1742 | deleteBtn.innerHTML = "✕"; |
| be59e71 | | | 1743 | deleteBtn.addEventListener("click", () => deleteNote(note.id, note.diagramId)); |
| be59e71 | | | 1744 | |
| be59e71 | | | 1745 | actions.appendChild(editBtn); |
| be59e71 | | | 1746 | actions.appendChild(deleteBtn); |
| be59e71 | | | 1747 | |
| be59e71 | | | 1748 | card.appendChild(meta); |
| be59e71 | | | 1749 | card.appendChild(textEl); |
| be59e71 | | | 1750 | card.appendChild(actions); |
| bdb18c9 | | | 1751 | list.appendChild(card); |
| bdb18c9 | | | 1752 | }); |
| bdb18c9 | | | 1753 | } |
| bdb18c9 | | | 1754 | |
| bdb18c9 | | | 1755 | function highlightNoteCard(noteId) { |
| bdb18c9 | | | 1756 | const card = document.getElementById(`note-${noteId}`); |
| bdb18c9 | | | 1757 | if (!card) return; |
| bdb18c9 | | | 1758 | card.scrollIntoView({ behavior: "smooth", block: "center" }); |
| bdb18c9 | | | 1759 | card.style.outline = "2px solid var(--note-border)"; |
| bdb18c9 | | | 1760 | setTimeout(() => card.style.outline = "", 2000); |
| bdb18c9 | | | 1761 | } |
| bdb18c9 | | | 1762 | |
| bdb18c9 | | | 1763 | async function switchTab(index) { |
| cee484a | | | 1764 | clearNodeFocus(); |
| bdb18c9 | | | 1765 | currentTab = index; |
| bdb18c9 | | | 1766 | buildDrawer(); |
| bdb18c9 | | | 1767 | await renderDiagram(index); |
| bdb18c9 | | | 1768 | renderNotesList(DIAGRAMS[index].id); |
| bdb18c9 | | | 1769 | |
| bdb18c9 | | | 1770 | // Rebuild editor with this diagram's Yjs doc |
| bdb18c9 | | | 1771 | rebuildEditorForDiagram(DIAGRAMS[index].id, DIAGRAMS[index].code); |
| bdb18c9 | | | 1772 | |
| bdb18c9 | | | 1773 | setEditorDirty(false); |
| bdb18c9 | | | 1774 | const status = document.getElementById("codePaneStatus"); |
| bdb18c9 | | | 1775 | if (status) { status.textContent = ""; status.className = "code-pane-status"; } |
| bdb18c9 | | | 1776 | // Fit diagram to view on tab switch |
| bdb18c9 | | | 1777 | requestAnimationFrame(() => zoomReset()); |
| bdb18c9 | | | 1778 | |
| bdb18c9 | | | 1779 | // Scroll active drawer item into view |
| bdb18c9 | | | 1780 | const activeItem = document.querySelector(".drawer-item.active"); |
| bdb18c9 | | | 1781 | if (activeItem) activeItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); |
| bdb18c9 | | | 1782 | |
| bdb18c9 | | | 1783 | // Notify server |
| bdb18c9 | | | 1784 | if (socket) { |
| bdb18c9 | | | 1785 | socket.emit("cursor-move", { x: 0, y: 0, activeTab: DIAGRAMS[index].id }); |
| bdb18c9 | | | 1786 | } |
| bdb18c9 | | | 1787 | } |
| bdb18c9 | | | 1788 | |
| bdb18c9 | | | 1789 | // ── Room / Socket ── |
| 2a9592c | | | 1790 | async function joinRoom(owner, repo, token, user) { |
| 2a9592c | | | 1791 | currentOwner = owner; |
| 2a9592c | | | 1792 | currentRepo = repo; |
| 2a9592c | | | 1793 | myName = user.display_name || user.username || "Anonymous"; |
| 2a9592c | | | 1794 | roomId = `${owner}/${repo}`; |
| 2a9592c | | | 1795 | |
| 1e755c0 | | | 1796 | if (DIAGRAMS.length === 0) { |
| 2a9592c | | | 1797 | await loadDiagramsFromServer(owner, repo); |
| 1e755c0 | | | 1798 | buildDrawer(); |
| 1e755c0 | | | 1799 | } |
| bdb18c9 | | | 1800 | |
| e629dcb | | | 1801 | setBreadcrumbs(owner, repo); |
| bdb18c9 | | | 1802 | |
| 2a9592c | | | 1803 | // Connect to the /collab namespace with auth token |
| 2a9592c | | | 1804 | socket = io("/collab", { auth: { token } }); |
| bdb18c9 | | | 1805 | |
| bdb18c9 | | | 1806 | socket.on("connect", () => { |
| 2a9592c | | | 1807 | socket.emit("join-room", { owner, repo }); |
| 2a9592c | | | 1808 | }); |
| 2a9592c | | | 1809 | |
| 2a9592c | | | 1810 | socket.on("connect_error", (err) => { |
| 2a9592c | | | 1811 | if (err.message === "unauthorized") { |
| 515cc68 | | | 1812 | console.warn("[collab] Socket auth failed — running in read-only mode"); |
| 515cc68 | | | 1813 | socket.disconnect(); |
| 2a9592c | | | 1814 | } |
| bdb18c9 | | | 1815 | }); |
| bdb18c9 | | | 1816 | |
| bdb18c9 | | | 1817 | socket.on("room-state", (state) => { |
| bdb18c9 | | | 1818 | allNotes = state.notes || {}; |
| bdb18c9 | | | 1819 | allUsers = state.users || {}; |
| bdb18c9 | | | 1820 | buildDrawer(); |
| bdb18c9 | | | 1821 | switchTab(0); |
| bdb18c9 | | | 1822 | updatePresence(); |
| bdb18c9 | | | 1823 | }); |
| bdb18c9 | | | 1824 | |
| bdb18c9 | | | 1825 | socket.on("users-updated", (users) => { |
| bdb18c9 | | | 1826 | allUsers = users; |
| bdb18c9 | | | 1827 | updatePresence(); |
| bdb18c9 | | | 1828 | buildDrawer(); |
| bdb18c9 | | | 1829 | }); |
| bdb18c9 | | | 1830 | |
| bdb18c9 | | | 1831 | socket.on("cursor-updated", ({ userId, x, y, activeTab, name, color }) => { |
| bdb18c9 | | | 1832 | // Update user's active tab in allUsers so drawer dots reflect it |
| bdb18c9 | | | 1833 | if (allUsers[userId]) { |
| bdb18c9 | | | 1834 | const prev = allUsers[userId].activeTab; |
| bdb18c9 | | | 1835 | allUsers[userId].activeTab = activeTab; |
| bdb18c9 | | | 1836 | if (prev !== activeTab) buildDrawer(); |
| bdb18c9 | | | 1837 | } |
| bdb18c9 | | | 1838 | updateRemoteCursor(userId, x, y, activeTab, name, color); |
| bdb18c9 | | | 1839 | }); |
| bdb18c9 | | | 1840 | |
| bdb18c9 | | | 1841 | socket.on("cursor-removed", ({ userId }) => { |
| bdb18c9 | | | 1842 | removeRemoteCursor(userId); |
| bdb18c9 | | | 1843 | }); |
| bdb18c9 | | | 1844 | |
| bdb18c9 | | | 1845 | // ── Yjs sync over socket.io ── |
| bdb18c9 | | | 1846 | socket.on("yjs-sync", async ({ diagramId, update }) => { |
| bdb18c9 | | | 1847 | const Y = window._Y; |
| bdb18c9 | | | 1848 | if (!Y || !ydocs[diagramId]) return; |
| bdb18c9 | | | 1849 | const ydoc = ydocs[diagramId]; |
| bdb18c9 | | | 1850 | const buf = Uint8Array.from(atob(update), c => c.charCodeAt(0)); |
| bdb18c9 | | | 1851 | Y.applyUpdate(ydoc, buf, "remote"); |
| bdb18c9 | | | 1852 | // If server had no content, initialize with default code |
| bdb18c9 | | | 1853 | const ytext = ydoc.getText("code"); |
| bdb18c9 | | | 1854 | if (ytext.length === 0 && ydoc._defaultCode) { |
| bdb18c9 | | | 1855 | ytext.insert(0, ydoc._defaultCode); |
| bdb18c9 | | | 1856 | delete ydoc._defaultCode; |
| bdb18c9 | | | 1857 | } |
| bdb18c9 | | | 1858 | // Re-render diagram from synced content |
| bdb18c9 | | | 1859 | const idx = DIAGRAMS.findIndex(d => d.id === diagramId); |
| bdb18c9 | | | 1860 | if (idx !== -1 && currentTab === idx) { |
| bdb18c9 | | | 1861 | const code = ytext.toString(); |
| bdb18c9 | | | 1862 | DIAGRAMS[idx].code = code; |
| bdb18c9 | | | 1863 | await renderDiagram(idx); |
| bdb18c9 | | | 1864 | requestAnimationFrame(() => zoomReset()); |
| bdb18c9 | | | 1865 | } |
| bdb18c9 | | | 1866 | }); |
| bdb18c9 | | | 1867 | |
| bdb18c9 | | | 1868 | socket.on("yjs-update", ({ diagramId, update }) => { |
| bdb18c9 | | | 1869 | const Y = window._Y; |
| bdb18c9 | | | 1870 | if (!Y || !ydocs[diagramId]) return; |
| bdb18c9 | | | 1871 | const ydoc = ydocs[diagramId]; |
| bdb18c9 | | | 1872 | const buf = Uint8Array.from(atob(update), c => c.charCodeAt(0)); |
| bdb18c9 | | | 1873 | Y.applyUpdate(ydoc, buf, "remote"); |
| bdb18c9 | | | 1874 | }); |
| bdb18c9 | | | 1875 | |
| bdb18c9 | | | 1876 | // Remote user hit Run — re-render the diagram on our side |
| bdb18c9 | | | 1877 | socket.on("diagram-code", async ({ diagramId, code }) => { |
| bdb18c9 | | | 1878 | const idx = DIAGRAMS.findIndex(d => d.id === diagramId); |
| bdb18c9 | | | 1879 | if (idx === -1) return; |
| bdb18c9 | | | 1880 | DIAGRAMS[idx].code = code; |
| bdb18c9 | | | 1881 | if (currentTab === idx) { |
| bdb18c9 | | | 1882 | await renderDiagram(idx); |
| bdb18c9 | | | 1883 | requestAnimationFrame(() => zoomReset()); |
| bdb18c9 | | | 1884 | setEditorDirty(false); |
| bdb18c9 | | | 1885 | const status = document.getElementById("codePaneStatus"); |
| bdb18c9 | | | 1886 | status.textContent = "\u2713 synced"; |
| bdb18c9 | | | 1887 | status.className = "code-pane-status ok"; |
| bdb18c9 | | | 1888 | } |
| bdb18c9 | | | 1889 | }); |
| bdb18c9 | | | 1890 | |
| bdb18c9 | | | 1891 | socket.on("note-added", (note) => { |
| bdb18c9 | | | 1892 | if (!allNotes[note.diagramId]) allNotes[note.diagramId] = []; |
| bdb18c9 | | | 1893 | allNotes[note.diagramId].push(note); |
| bdb18c9 | | | 1894 | if (DIAGRAMS[currentTab].id === note.diagramId) { |
| bdb18c9 | | | 1895 | renderNotesList(note.diagramId); |
| bdb18c9 | | | 1896 | } |
| bdb18c9 | | | 1897 | buildDrawer(); |
| bdb18c9 | | | 1898 | }); |
| bdb18c9 | | | 1899 | |
| bdb18c9 | | | 1900 | socket.on("note-edited", ({ noteId, diagramId, text, editedAt }) => { |
| bdb18c9 | | | 1901 | const notes = allNotes[diagramId] || []; |
| bdb18c9 | | | 1902 | const note = notes.find(n => n.id === noteId); |
| bdb18c9 | | | 1903 | if (note) { |
| bdb18c9 | | | 1904 | note.text = text; |
| bdb18c9 | | | 1905 | note.editedAt = editedAt; |
| bdb18c9 | | | 1906 | } |
| bdb18c9 | | | 1907 | if (DIAGRAMS[currentTab].id === diagramId) { |
| bdb18c9 | | | 1908 | renderNotesList(diagramId); |
| bdb18c9 | | | 1909 | } |
| bdb18c9 | | | 1910 | }); |
| bdb18c9 | | | 1911 | |
| bdb18c9 | | | 1912 | socket.on("note-deleted", ({ noteId, diagramId }) => { |
| bdb18c9 | | | 1913 | if (allNotes[diagramId]) { |
| bdb18c9 | | | 1914 | allNotes[diagramId] = allNotes[diagramId].filter(n => n.id !== noteId); |
| bdb18c9 | | | 1915 | } |
| bdb18c9 | | | 1916 | if (DIAGRAMS[currentTab].id === diagramId) { |
| bdb18c9 | | | 1917 | renderNotesList(diagramId); |
| bdb18c9 | | | 1918 | } |
| bdb18c9 | | | 1919 | buildDrawer(); |
| bdb18c9 | | | 1920 | }); |
| bdb18c9 | | | 1921 | } |
| bdb18c9 | | | 1922 | |
| bdb18c9 | | | 1923 | // ── Keyboard navigation ── |
| bdb18c9 | | | 1924 | document.addEventListener("keydown", (e) => { |
| bdb18c9 | | | 1925 | // Ctrl+Enter or Cmd+Enter = commit and render diagram |
| bdb18c9 | | | 1926 | if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { |
| bdb18c9 | | | 1927 | e.preventDefault(); |
| bdb18c9 | | | 1928 | commitDiagramCode(); |
| bdb18c9 | | | 1929 | return; |
| bdb18c9 | | | 1930 | } |
| bdb18c9 | | | 1931 | |
| bdb18c9 | | | 1932 | // Shift+Alt+F = format (works even inside the editor) |
| bdb18c9 | | | 1933 | if (e.key === "F" && e.shiftKey && e.altKey) { |
| bdb18c9 | | | 1934 | e.preventDefault(); |
| bdb18c9 | | | 1935 | formatMermaid(); |
| bdb18c9 | | | 1936 | return; |
| bdb18c9 | | | 1937 | } |
| bdb18c9 | | | 1938 | |
| bdb18c9 | | | 1939 | // Don't intercept when typing in inputs |
| bdb18c9 | | | 1940 | if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.closest(".cm-editor")) return; |
| bdb18c9 | | | 1941 | |
| bdb18c9 | | | 1942 | if (e.key === "ArrowDown" || e.key === "j") { |
| bdb18c9 | | | 1943 | e.preventDefault(); |
| bdb18c9 | | | 1944 | if (currentTab < DIAGRAMS.length - 1) switchTab(currentTab + 1); |
| bdb18c9 | | | 1945 | } else if (e.key === "ArrowUp" || e.key === "k") { |
| bdb18c9 | | | 1946 | e.preventDefault(); |
| bdb18c9 | | | 1947 | if (currentTab > 0) switchTab(currentTab - 1); |
| bdb18c9 | | | 1948 | } |
| bdb18c9 | | | 1949 | }); |
| bdb18c9 | | | 1950 | |
| bdb18c9 | | | 1951 | // ── Cursor tracking ── |
| bdb18c9 | | | 1952 | document.getElementById("diagramPane").addEventListener("mousemove", (e) => { |
| bdb18c9 | | | 1953 | if (!socket) return; |
| bdb18c9 | | | 1954 | const pane = document.getElementById("diagramPane"); |
| bdb18c9 | | | 1955 | const rect = pane.getBoundingClientRect(); |
| bdb18c9 | | | 1956 | // Send content-space coords so they're zoom-independent |
| bdb18c9 | | | 1957 | socket.emit("cursor-move", { |
| bdb18c9 | | | 1958 | x: (e.clientX - rect.left - panZoom.x) / panZoom.scale, |
| bdb18c9 | | | 1959 | y: (e.clientY - rect.top - panZoom.y) / panZoom.scale, |
| bdb18c9 | | | 1960 | activeTab: DIAGRAMS[currentTab].id, |
| bdb18c9 | | | 1961 | }); |
| bdb18c9 | | | 1962 | }); |
| bdb18c9 | | | 1963 | |
| bdb18c9 | | | 1964 | function updateRemoteCursor(userId, x, y, activeTab, name, color) { |
| bdb18c9 | | | 1965 | if (activeTab !== DIAGRAMS[currentTab].id) { |
| bdb18c9 | | | 1966 | removeRemoteCursor(userId); |
| bdb18c9 | | | 1967 | return; |
| bdb18c9 | | | 1968 | } |
| bdb18c9 | | | 1969 | |
| bdb18c9 | | | 1970 | let el = remoteCursors[userId]; |
| bdb18c9 | | | 1971 | if (!el) { |
| bdb18c9 | | | 1972 | el = document.createElement("div"); |
| bdb18c9 | | | 1973 | el.className = "remote-cursor"; |
| bdb18c9 | | | 1974 | el.innerHTML = ` |
| bdb18c9 | | | 1975 | <span class="cursor-dot" style="background:${color}"></span> |
| bdb18c9 | | | 1976 | <span class="cursor-label" style="background:${color}">${escapeHtml(name)}</span> |
| bdb18c9 | | | 1977 | `; |
| bdb18c9 | | | 1978 | document.body.appendChild(el); |
| bdb18c9 | | | 1979 | remoteCursors[userId] = el; |
| bdb18c9 | | | 1980 | } |
| bdb18c9 | | | 1981 | |
| bdb18c9 | | | 1982 | // Briefly show the name label on movement |
| bdb18c9 | | | 1983 | el.classList.add("recent"); |
| bdb18c9 | | | 1984 | clearTimeout(el._labelTimer); |
| bdb18c9 | | | 1985 | el._labelTimer = setTimeout(() => el.classList.remove("recent"), 2000); |
| bdb18c9 | | | 1986 | |
| bf1f995 | | | 1987 | // Store content-space coords so we can reposition on zoom/pan |
| bf1f995 | | | 1988 | el._contentX = x; |
| bf1f995 | | | 1989 | el._contentY = y; |
| bf1f995 | | | 1990 | |
| bf1f995 | | | 1991 | positionCursorElement(el); |
| bf1f995 | | | 1992 | } |
| bf1f995 | | | 1993 | |
| bf1f995 | | | 1994 | function positionCursorElement(el) { |
| bdb18c9 | | | 1995 | const pane = document.getElementById("diagramPane"); |
| bdb18c9 | | | 1996 | const rect = pane.getBoundingClientRect(); |
| bf1f995 | | | 1997 | const screenX = rect.left + panZoom.x + el._contentX * panZoom.scale; |
| bf1f995 | | | 1998 | const screenY = rect.top + panZoom.y + el._contentY * panZoom.scale; |
| bdb18c9 | | | 1999 | |
| bdb18c9 | | | 2000 | if (screenX < rect.left || screenX > rect.right || screenY < rect.top || screenY > rect.bottom) { |
| bdb18c9 | | | 2001 | el.style.display = "none"; |
| bdb18c9 | | | 2002 | } else { |
| bdb18c9 | | | 2003 | el.style.display = "flex"; |
| bdb18c9 | | | 2004 | el.style.left = screenX - 5 + "px"; |
| bdb18c9 | | | 2005 | el.style.top = screenY - 5 + "px"; |
| bdb18c9 | | | 2006 | } |
| bdb18c9 | | | 2007 | } |
| bdb18c9 | | | 2008 | |
| bf1f995 | | | 2009 | function repositionRemoteCursors() { |
| bf1f995 | | | 2010 | for (const el of Object.values(remoteCursors)) { |
| bf1f995 | | | 2011 | if (el._contentX != null) positionCursorElement(el); |
| bf1f995 | | | 2012 | } |
| bf1f995 | | | 2013 | } |
| bf1f995 | | | 2014 | |
| bdb18c9 | | | 2015 | function removeRemoteCursor(userId) { |
| bdb18c9 | | | 2016 | if (remoteCursors[userId]) { |
| bdb18c9 | | | 2017 | remoteCursors[userId].remove(); |
| bdb18c9 | | | 2018 | delete remoteCursors[userId]; |
| bdb18c9 | | | 2019 | } |
| bdb18c9 | | | 2020 | } |
| bdb18c9 | | | 2021 | |
| bdb18c9 | | | 2022 | // ── Presence bar ── |
| bdb18c9 | | | 2023 | function updatePresence() { |
| bdb18c9 | | | 2024 | const bar = document.getElementById("presenceBar"); |
| bdb18c9 | | | 2025 | bar.innerHTML = ""; |
| bdb18c9 | | | 2026 | Object.values(allUsers).forEach(u => { |
| bdb18c9 | | | 2027 | const dot = document.createElement("div"); |
| bdb18c9 | | | 2028 | dot.className = "presence-dot"; |
| bdb18c9 | | | 2029 | dot.style.background = u.color; |
| bdb18c9 | | | 2030 | dot.textContent = (u.name || "?")[0].toUpperCase(); |
| bdb18c9 | | | 2031 | dot.innerHTML += `<span class="tooltip">${escapeHtml(u.name)}</span>`; |
| bdb18c9 | | | 2032 | bar.appendChild(dot); |
| bdb18c9 | | | 2033 | }); |
| bdb18c9 | | | 2034 | } |
| bdb18c9 | | | 2035 | |
| bdb18c9 | | | 2036 | // ── Notes ── |
| bdb18c9 | | | 2037 | // ── Code pane (CodeMirror 6) ── |
| bdb18c9 | | | 2038 | let codeRenderTimeout = null; |
| bdb18c9 | | | 2039 | let cmEditor = null; |
| bdb18c9 | | | 2040 | let cmReady = false; |
| bdb18c9 | | | 2041 | let editorFontSize = 12; |
| bdb18c9 | | | 2042 | |
| bdb18c9 | | | 2043 | function changeEditorFontSize(delta) { |
| bdb18c9 | | | 2044 | editorFontSize = Math.max(8, Math.min(24, editorFontSize + delta)); |
| bdb18c9 | | | 2045 | if (cmEditor) { |
| bdb18c9 | | | 2046 | cmEditor.dom.style.fontSize = editorFontSize + "px"; |
| bdb18c9 | | | 2047 | cmEditor.requestMeasure(); |
| bdb18c9 | | | 2048 | } |
| bdb18c9 | | | 2049 | } |
| bdb18c9 | | | 2050 | |
| bdb18c9 | | | 2051 | function toggleCodePane() { |
| bdb18c9 | | | 2052 | const pane = document.getElementById("codePane"); |
| bdb18c9 | | | 2053 | pane.classList.toggle("open"); |
| bdb18c9 | | | 2054 | // Clear any inline width from manual resize so CSS takes over |
| bdb18c9 | | | 2055 | pane.style.width = ""; |
| bdb18c9 | | | 2056 | if (pane.classList.contains("open")) { |
| bdb18c9 | | | 2057 | setTimeout(() => zoomReset(), 220); |
| bdb18c9 | | | 2058 | } |
| bdb18c9 | | | 2059 | } |
| bdb18c9 | | | 2060 | |
| bdb18c9 | | | 2061 | // ── Yjs document management ── |
| bdb18c9 | | | 2062 | const ydocs = {}; // { diagramId: Y.Doc } |
| bdb18c9 | | | 2063 | let currentYtext = null; |
| bdb18c9 | | | 2064 | let currentUndoManager = null; |
| bdb18c9 | | | 2065 | |
| bdb18c9 | | | 2066 | function uint8ToBase64(uint8) { |
| bdb18c9 | | | 2067 | let binary = ""; |
| bdb18c9 | | | 2068 | for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]); |
| bdb18c9 | | | 2069 | return btoa(binary); |
| bdb18c9 | | | 2070 | } |
| bdb18c9 | | | 2071 | |
| bdb18c9 | | | 2072 | function getOrCreateYDoc(diagramId) { |
| bdb18c9 | | | 2073 | const Y = window._Y; |
| bdb18c9 | | | 2074 | if (!Y) return null; |
| bdb18c9 | | | 2075 | if (!ydocs[diagramId]) { |
| bdb18c9 | | | 2076 | const ydoc = new Y.Doc(); |
| bdb18c9 | | | 2077 | ydocs[diagramId] = ydoc; |
| bdb18c9 | | | 2078 | // When Yjs text changes locally, send update to server |
| bdb18c9 | | | 2079 | ydoc.on("update", (update, origin) => { |
| bdb18c9 | | | 2080 | if (origin === "remote") return; |
| bdb18c9 | | | 2081 | if (socket) { |
| bdb18c9 | | | 2082 | socket.emit("yjs-update", { diagramId, update: uint8ToBase64(update) }); |
| bdb18c9 | | | 2083 | } |
| bdb18c9 | | | 2084 | }); |
| bdb18c9 | | | 2085 | } |
| bdb18c9 | | | 2086 | return ydocs[diagramId]; |
| bdb18c9 | | | 2087 | } |
| bdb18c9 | | | 2088 | |
| bdb18c9 | | | 2089 | function syncYDoc(diagramId, defaultCode) { |
| bdb18c9 | | | 2090 | if (!socket) return; |
| bdb18c9 | | | 2091 | // Store default code so we can initialize if server has no state |
| bdb18c9 | | | 2092 | if (defaultCode) ydocs[diagramId]._defaultCode = defaultCode; |
| bdb18c9 | | | 2093 | socket.emit("yjs-sync", { diagramId }); |
| bdb18c9 | | | 2094 | } |
| bdb18c9 | | | 2095 | |
| bdb18c9 | | | 2096 | let _suppressEditorDirty = false; |
| bdb18c9 | | | 2097 | |
| bdb18c9 | | | 2098 | function updateCodePane(code) { |
| bdb18c9 | | | 2099 | if (cmEditor) { |
| bdb18c9 | | | 2100 | const cur = cmEditor.state.doc.toString(); |
| bdb18c9 | | | 2101 | if (cur !== code) { |
| bdb18c9 | | | 2102 | _suppressEditorDirty = true; |
| bdb18c9 | | | 2103 | cmEditor.dispatch({ |
| bdb18c9 | | | 2104 | changes: { from: 0, to: cur.length, insert: code } |
| bdb18c9 | | | 2105 | }); |
| bdb18c9 | | | 2106 | _suppressEditorDirty = false; |
| bdb18c9 | | | 2107 | } |
| bdb18c9 | | | 2108 | } |
| bdb18c9 | | | 2109 | } |
| bdb18c9 | | | 2110 | |
| bdb18c9 | | | 2111 | function rebuildEditorForDiagram(diagramId, defaultCode) { |
| bdb18c9 | | | 2112 | if (!window._cmCreateEditor) return; |
| bdb18c9 | | | 2113 | const container = document.getElementById("codePaneEditor"); |
| bdb18c9 | | | 2114 | |
| bdb18c9 | | | 2115 | // Destroy previous editor |
| bdb18c9 | | | 2116 | if (cmEditor) { |
| bdb18c9 | | | 2117 | cmEditor.destroy(); |
| bdb18c9 | | | 2118 | cmEditor = null; |
| bdb18c9 | | | 2119 | } |
| bdb18c9 | | | 2120 | |
| bdb18c9 | | | 2121 | const Y = window._Y; |
| bdb18c9 | | | 2122 | if (Y && socket) { |
| bdb18c9 | | | 2123 | // Collaborative mode: use Yjs |
| bdb18c9 | | | 2124 | const ydoc = getOrCreateYDoc(diagramId); |
| bdb18c9 | | | 2125 | const ytext = ydoc.getText("code"); |
| bdb18c9 | | | 2126 | currentYtext = ytext; |
| bdb18c9 | | | 2127 | currentUndoManager = new Y.UndoManager(ytext); |
| bdb18c9 | | | 2128 | |
| bdb18c9 | | | 2129 | cmEditor = window._cmCreateEditor(container, "", (code) => { |
| bdb18c9 | | | 2130 | if (_suppressEditorDirty) return; |
| bdb18c9 | | | 2131 | setEditorDirty(true); |
| bdb18c9 | | | 2132 | clearTimeout(codeRenderTimeout); |
| bdb18c9 | | | 2133 | codeRenderTimeout = setTimeout(() => validateMermaidCode(code), 500); |
| bdb18c9 | | | 2134 | }, ytext, currentUndoManager); |
| bdb18c9 | | | 2135 | |
| bdb18c9 | | | 2136 | // Request latest state from server; pass default code for first-time init |
| bdb18c9 | | | 2137 | syncYDoc(diagramId, defaultCode); |
| bdb18c9 | | | 2138 | } else { |
| bdb18c9 | | | 2139 | // Standalone mode: no Yjs |
| bdb18c9 | | | 2140 | currentYtext = null; |
| bdb18c9 | | | 2141 | currentUndoManager = null; |
| bdb18c9 | | | 2142 | cmEditor = window._cmCreateEditor(container, defaultCode || "", (code) => { |
| bdb18c9 | | | 2143 | if (_suppressEditorDirty) return; |
| bdb18c9 | | | 2144 | setEditorDirty(true); |
| bdb18c9 | | | 2145 | clearTimeout(codeRenderTimeout); |
| bdb18c9 | | | 2146 | codeRenderTimeout = setTimeout(() => validateMermaidCode(code), 500); |
| bdb18c9 | | | 2147 | }); |
| bdb18c9 | | | 2148 | } |
| bdb18c9 | | | 2149 | cmReady = true; |
| bdb18c9 | | | 2150 | } |
| bdb18c9 | | | 2151 | |
| bdb18c9 | | | 2152 | // ── Mermaid auto-formatter ── |
| bdb18c9 | | | 2153 | function formatMermaid() { |
| bdb18c9 | | | 2154 | if (!cmEditor) return; |
| bdb18c9 | | | 2155 | const src = cmEditor.state.doc.toString(); |
| bdb18c9 | | | 2156 | const formatted = formatMermaidCode(src); |
| bdb18c9 | | | 2157 | if (formatted !== src) { |
| bdb18c9 | | | 2158 | cmEditor.dispatch({ |
| bdb18c9 | | | 2159 | changes: { from: 0, to: src.length, insert: formatted } |
| bdb18c9 | | | 2160 | }); |
| bdb18c9 | | | 2161 | } |
| bdb18c9 | | | 2162 | } |
| bdb18c9 | | | 2163 | |
| bdb18c9 | | | 2164 | function formatMermaidCode(src) { |
| bdb18c9 | | | 2165 | const lines = src.split("\n"); |
| bdb18c9 | | | 2166 | const out = []; |
| bdb18c9 | | | 2167 | const INDENT = " "; // 4 spaces |
| bdb18c9 | | | 2168 | let depth = 0; |
| bdb18c9 | | | 2169 | |
| bdb18c9 | | | 2170 | // Keywords that open a block (increase indent after this line) |
| bdb18c9 | | | 2171 | const openers = /^(subgraph|state|loop|alt|par|critical|opt)\b/; |
| bdb18c9 | | | 2172 | // Keywords that close a block (decrease indent before this line) |
| bdb18c9 | | | 2173 | const closers = /^end\b/; |
| bdb18c9 | | | 2174 | // "else" is at same level as the opener, then content indented again |
| bdb18c9 | | | 2175 | const midBlock = /^(else|break)\b/; |
| bdb18c9 | | | 2176 | // Brace openers for ER/class: "entity_name {" or "class Foo {" |
| bdb18c9 | | | 2177 | const braceOpen = /\{\s*$/; |
| bdb18c9 | | | 2178 | const braceClose = /^\s*\}/; |
| bdb18c9 | | | 2179 | // Diagram type declarations stay at column 0 |
| bdb18c9 | | | 2180 | const diagramType = /^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/; |
| bdb18c9 | | | 2181 | |
| bdb18c9 | | | 2182 | for (let i = 0; i < lines.length; i++) { |
| bdb18c9 | | | 2183 | const trimmed = lines[i].trim(); |
| bdb18c9 | | | 2184 | |
| bdb18c9 | | | 2185 | // Blank lines pass through |
| bdb18c9 | | | 2186 | if (trimmed === "") { out.push(""); continue; } |
| bdb18c9 | | | 2187 | |
| bdb18c9 | | | 2188 | // Comments stay at current indent level |
| bdb18c9 | | | 2189 | if (trimmed.startsWith("%%")) { |
| bdb18c9 | | | 2190 | out.push(INDENT.repeat(depth) + trimmed); |
| bdb18c9 | | | 2191 | continue; |
| bdb18c9 | | | 2192 | } |
| bdb18c9 | | | 2193 | |
| bdb18c9 | | | 2194 | // Diagram type at column 0, reset depth |
| bdb18c9 | | | 2195 | if (diagramType.test(trimmed)) { |
| bdb18c9 | | | 2196 | depth = 0; |
| bdb18c9 | | | 2197 | out.push(trimmed); |
| bdb18c9 | | | 2198 | continue; |
| bdb18c9 | | | 2199 | } |
| bdb18c9 | | | 2200 | |
| bdb18c9 | | | 2201 | // Brace close: dedent before |
| bdb18c9 | | | 2202 | if (braceClose.test(trimmed)) { |
| bdb18c9 | | | 2203 | depth = Math.max(0, depth - 1); |
| bdb18c9 | | | 2204 | out.push(INDENT.repeat(depth) + trimmed); |
| bdb18c9 | | | 2205 | continue; |
| bdb18c9 | | | 2206 | } |
| bdb18c9 | | | 2207 | |
| bdb18c9 | | | 2208 | // "end" keyword: dedent before |
| bdb18c9 | | | 2209 | if (closers.test(trimmed)) { |
| bdb18c9 | | | 2210 | depth = Math.max(0, depth - 1); |
| bdb18c9 | | | 2211 | out.push(INDENT.repeat(depth) + trimmed); |
| bdb18c9 | | | 2212 | continue; |
| bdb18c9 | | | 2213 | } |
| bdb18c9 | | | 2214 | |
| bdb18c9 | | | 2215 | // Mid-block keywords (else): dedent for this line, re-indent after |
| bdb18c9 | | | 2216 | if (midBlock.test(trimmed)) { |
| bdb18c9 | | | 2217 | out.push(INDENT.repeat(Math.max(0, depth - 1)) + trimmed); |
| bdb18c9 | | | 2218 | continue; |
| bdb18c9 | | | 2219 | } |
| bdb18c9 | | | 2220 | |
| bdb18c9 | | | 2221 | // Normal line at current depth |
| bdb18c9 | | | 2222 | out.push(INDENT.repeat(depth) + trimmed); |
| bdb18c9 | | | 2223 | |
| bdb18c9 | | | 2224 | // After outputting, check if this line opens a block |
| bdb18c9 | | | 2225 | if (openers.test(trimmed) || braceOpen.test(trimmed)) { |
| bdb18c9 | | | 2226 | depth++; |
| bdb18c9 | | | 2227 | } |
| bdb18c9 | | | 2228 | } |
| bdb18c9 | | | 2229 | |
| bdb18c9 | | | 2230 | return out.join("\n"); |
| bdb18c9 | | | 2231 | } |
| bdb18c9 | | | 2232 | |
| bdb18c9 | | | 2233 | let editorDirty = false; |
| bdb18c9 | | | 2234 | |
| bdb18c9 | | | 2235 | function setEditorDirty(dirty) { |
| bdb18c9 | | | 2236 | editorDirty = dirty; |
| bdb18c9 | | | 2237 | const btn = document.getElementById("codePanePlay"); |
| bdb18c9 | | | 2238 | if (btn) btn.classList.toggle("dirty", dirty); |
| bdb18c9 | | | 2239 | } |
| bdb18c9 | | | 2240 | |
| bdb18c9 | | | 2241 | async function validateMermaidCode(code) { |
| bdb18c9 | | | 2242 | const status = document.getElementById("codePaneStatus"); |
| bdb18c9 | | | 2243 | const diag = document.getElementById("codePaneDiagnostics"); |
| bdb18c9 | | | 2244 | try { |
| bdb18c9 | | | 2245 | await mermaid.parse(code); |
| bdb18c9 | | | 2246 | status.textContent = "\u2713 valid"; |
| bdb18c9 | | | 2247 | status.className = "code-pane-status ok"; |
| bdb18c9 | | | 2248 | diag.className = "code-pane-diagnostics"; |
| bdb18c9 | | | 2249 | diag.textContent = ""; |
| bdb18c9 | | | 2250 | return true; |
| bdb18c9 | | | 2251 | } catch (e) { |
| bdb18c9 | | | 2252 | const raw = e.message || String(e); |
| bdb18c9 | | | 2253 | status.textContent = "\u2717 error"; |
| bdb18c9 | | | 2254 | status.className = "code-pane-status error"; |
| bdb18c9 | | | 2255 | |
| bdb18c9 | | | 2256 | // Extract line/col from mermaid error (formats vary) |
| bdb18c9 | | | 2257 | let line = null, col = null; |
| bdb18c9 | | | 2258 | const lineMatch = raw.match(/line\s*(\d+)/i) || raw.match(/at line (\d+)/i); |
| bdb18c9 | | | 2259 | const colMatch = raw.match(/col(?:umn)?\s*(\d+)/i); |
| bdb18c9 | | | 2260 | if (lineMatch) line = parseInt(lineMatch[1]); |
| bdb18c9 | | | 2261 | if (colMatch) col = parseInt(colMatch[1]); |
| bdb18c9 | | | 2262 | |
| bdb18c9 | | | 2263 | // Also check e.hash (jison parser data) |
| bdb18c9 | | | 2264 | if (!line && e.hash) { |
| bdb18c9 | | | 2265 | if (e.hash.line != null) line = e.hash.line + 1; |
| bdb18c9 | | | 2266 | if (e.hash.loc) { |
| bdb18c9 | | | 2267 | line = line || (e.hash.loc.first_line); |
| bdb18c9 | | | 2268 | col = col || (e.hash.loc.first_column + 1); |
| bdb18c9 | | | 2269 | } |
| bdb18c9 | | | 2270 | } |
| bdb18c9 | | | 2271 | |
| bdb18c9 | | | 2272 | // Build diagnostics message |
| bdb18c9 | | | 2273 | let msg = ""; |
| bdb18c9 | | | 2274 | if (line) { |
| bdb18c9 | | | 2275 | msg += "Line " + line; |
| bdb18c9 | | | 2276 | if (col) msg += ":" + col; |
| bdb18c9 | | | 2277 | msg += " \u2014 "; |
| bdb18c9 | | | 2278 | } |
| bdb18c9 | | | 2279 | // Clean up the error message |
| bdb18c9 | | | 2280 | let cleaned = raw.replace(/^Error:\s*/i, "").replace(/Syntax error in.*?:\s*/i, ""); |
| bdb18c9 | | | 2281 | // Take the first meaningful line |
| bdb18c9 | | | 2282 | const lines = cleaned.split("\n").filter(l => l.trim()); |
| bdb18c9 | | | 2283 | msg += lines[0] || cleaned; |
| bdb18c9 | | | 2284 | |
| bdb18c9 | | | 2285 | // If we can, show the offending source line |
| bdb18c9 | | | 2286 | if (line && code) { |
| bdb18c9 | | | 2287 | const srcLines = code.split("\n"); |
| bdb18c9 | | | 2288 | const srcLine = srcLines[line - 1]; |
| bdb18c9 | | | 2289 | if (srcLine != null) { |
| bdb18c9 | | | 2290 | msg += "\n\n " + line + " \u2502 " + srcLine; |
| bdb18c9 | | | 2291 | if (col) { |
| bdb18c9 | | | 2292 | msg += "\n " + " ".repeat(String(line).length) + " ".repeat(col) + "\u2191"; |
| bdb18c9 | | | 2293 | } |
| bdb18c9 | | | 2294 | } |
| bdb18c9 | | | 2295 | } |
| bdb18c9 | | | 2296 | |
| bdb18c9 | | | 2297 | diag.textContent = msg; |
| bdb18c9 | | | 2298 | diag.className = "code-pane-diagnostics visible"; |
| bdb18c9 | | | 2299 | |
| bdb18c9 | | | 2300 | // Jump editor cursor to error line |
| bdb18c9 | | | 2301 | if (line && cmEditor) { |
| bdb18c9 | | | 2302 | try { |
| bdb18c9 | | | 2303 | const pos = cmEditor.state.doc.line(line); |
| bdb18c9 | | | 2304 | cmEditor.dispatch({ |
| bdb18c9 | | | 2305 | selection: { anchor: pos.from + (col ? col - 1 : 0) }, |
| bdb18c9 | | | 2306 | scrollIntoView: true |
| bdb18c9 | | | 2307 | }); |
| bdb18c9 | | | 2308 | } catch (_) {} |
| bdb18c9 | | | 2309 | } |
| bdb18c9 | | | 2310 | |
| bdb18c9 | | | 2311 | return false; |
| bdb18c9 | | | 2312 | } |
| bdb18c9 | | | 2313 | } |
| bdb18c9 | | | 2314 | |
| bdb18c9 | | | 2315 | async function commitDiagramCode() { |
| bdb18c9 | | | 2316 | if (!cmEditor) return; |
| bdb18c9 | | | 2317 | const code = cmEditor.state.doc.toString(); |
| bdb18c9 | | | 2318 | const valid = await validateMermaidCode(code); |
| bdb18c9 | | | 2319 | if (!valid) return; |
| bdb18c9 | | | 2320 | DIAGRAMS[currentTab].code = code; |
| bdb18c9 | | | 2321 | await renderDiagram(currentTab); |
| bdb18c9 | | | 2322 | requestAnimationFrame(() => zoomReset()); |
| bdb18c9 | | | 2323 | setEditorDirty(false); |
| bdb18c9 | | | 2324 | // Code is already synced via Yjs — also broadcast render trigger to others |
| bdb18c9 | | | 2325 | if (socket) { |
| bdb18c9 | | | 2326 | socket.emit("diagram-code", { diagramId: DIAGRAMS[currentTab].id, code }); |
| bdb18c9 | | | 2327 | } |
| bdb18c9 | | | 2328 | } |
| bdb18c9 | | | 2329 | |
| bdb18c9 | | | 2330 | function initCodeMirror() { |
| bdb18c9 | | | 2331 | if (cmEditor || !window._cmCreateEditor) return; |
| bdb18c9 | | | 2332 | rebuildEditorForDiagram(DIAGRAMS[currentTab]?.id, DIAGRAMS[currentTab]?.code || ""); |
| bdb18c9 | | | 2333 | } |
| bdb18c9 | | | 2334 | |
| bdb18c9 | | | 2335 | // Init CM when the ESM module finishes loading |
| bdb18c9 | | | 2336 | if (window._cmCreateEditor) { |
| bdb18c9 | | | 2337 | initCodeMirror(); |
| bdb18c9 | | | 2338 | } else { |
| bdb18c9 | | | 2339 | window.addEventListener("cm-ready", () => initCodeMirror()); |
| bdb18c9 | | | 2340 | } |
| bdb18c9 | | | 2341 | |
| bdb18c9 | | | 2342 | // Resize handle — code pane |
| bdb18c9 | | | 2343 | (function() { |
| bdb18c9 | | | 2344 | const handle = document.getElementById("codePaneResize"); |
| bdb18c9 | | | 2345 | const pane = document.getElementById("codePane"); |
| bdb18c9 | | | 2346 | let startX, startWidth; |
| bdb18c9 | | | 2347 | |
| bdb18c9 | | | 2348 | handle.addEventListener("mousedown", (e) => { |
| bdb18c9 | | | 2349 | e.preventDefault(); |
| bdb18c9 | | | 2350 | startX = e.clientX; |
| bdb18c9 | | | 2351 | startWidth = pane.offsetWidth; |
| bdb18c9 | | | 2352 | handle.classList.add("dragging"); |
| bdb18c9 | | | 2353 | document.addEventListener("mousemove", onDrag); |
| bdb18c9 | | | 2354 | document.addEventListener("mouseup", onStop); |
| bdb18c9 | | | 2355 | }); |
| bdb18c9 | | | 2356 | |
| bdb18c9 | | | 2357 | handle.addEventListener("dblclick", () => { |
| bdb18c9 | | | 2358 | pane.style.width = ""; |
| bdb18c9 | | | 2359 | setTimeout(() => zoomReset(), 50); |
| bdb18c9 | | | 2360 | }); |
| bdb18c9 | | | 2361 | |
| bdb18c9 | | | 2362 | function onDrag(e) { |
| bdb18c9 | | | 2363 | const newWidth = Math.max(200, startWidth + (e.clientX - startX)); |
| bdb18c9 | | | 2364 | pane.style.width = newWidth + "px"; |
| bdb18c9 | | | 2365 | } |
| bdb18c9 | | | 2366 | |
| bdb18c9 | | | 2367 | function onStop() { |
| bdb18c9 | | | 2368 | handle.classList.remove("dragging"); |
| bdb18c9 | | | 2369 | document.removeEventListener("mousemove", onDrag); |
| bdb18c9 | | | 2370 | document.removeEventListener("mouseup", onStop); |
| bdb18c9 | | | 2371 | setTimeout(() => zoomReset(), 50); |
| bdb18c9 | | | 2372 | } |
| bdb18c9 | | | 2373 | })(); |
| bdb18c9 | | | 2374 | |
| bdb18c9 | | | 2375 | // Resize handle — notes sidebar |
| bdb18c9 | | | 2376 | (function() { |
| bdb18c9 | | | 2377 | const handle = document.getElementById("notesResize"); |
| bdb18c9 | | | 2378 | const pane = document.getElementById("notesSidebar"); |
| bdb18c9 | | | 2379 | let startX, startWidth; |
| bdb18c9 | | | 2380 | |
| bdb18c9 | | | 2381 | handle.addEventListener("mousedown", (e) => { |
| bdb18c9 | | | 2382 | e.preventDefault(); |
| bdb18c9 | | | 2383 | startX = e.clientX; |
| bdb18c9 | | | 2384 | startWidth = pane.offsetWidth; |
| bdb18c9 | | | 2385 | handle.classList.add("dragging"); |
| bdb18c9 | | | 2386 | document.addEventListener("mousemove", onDrag); |
| bdb18c9 | | | 2387 | document.addEventListener("mouseup", onStop); |
| bdb18c9 | | | 2388 | }); |
| bdb18c9 | | | 2389 | |
| bdb18c9 | | | 2390 | handle.addEventListener("dblclick", () => { |
| bdb18c9 | | | 2391 | pane.style.width = ""; |
| bdb18c9 | | | 2392 | setTimeout(() => zoomReset(), 50); |
| bdb18c9 | | | 2393 | }); |
| bdb18c9 | | | 2394 | |
| bdb18c9 | | | 2395 | function onDrag(e) { |
| bdb18c9 | | | 2396 | const newWidth = Math.max(180, startWidth - (e.clientX - startX)); |
| bdb18c9 | | | 2397 | pane.style.width = newWidth + "px"; |
| bdb18c9 | | | 2398 | } |
| bdb18c9 | | | 2399 | |
| bdb18c9 | | | 2400 | function onStop() { |
| bdb18c9 | | | 2401 | handle.classList.remove("dragging"); |
| bdb18c9 | | | 2402 | document.removeEventListener("mousemove", onDrag); |
| bdb18c9 | | | 2403 | document.removeEventListener("mouseup", onStop); |
| bdb18c9 | | | 2404 | setTimeout(() => zoomReset(), 50); |
| bdb18c9 | | | 2405 | } |
| bdb18c9 | | | 2406 | })(); |
| bdb18c9 | | | 2407 | |
| bdb18c9 | | | 2408 | function submitNote() { |
| bdb18c9 | | | 2409 | const input = document.getElementById("noteInput"); |
| bdb18c9 | | | 2410 | const text = input.value.trim(); |
| bdb18c9 | | | 2411 | if (!text || !socket) return; |
| bdb18c9 | | | 2412 | |
| bdb18c9 | | | 2413 | const diagram = DIAGRAMS[currentTab]; |
| bdb18c9 | | | 2414 | |
| bdb18c9 | | | 2415 | socket.emit("add-note", { |
| bdb18c9 | | | 2416 | text, |
| bdb18c9 | | | 2417 | diagramId: diagram.id, |
| bdb18c9 | | | 2418 | diagramTitle: diagram.title, |
| bdb18c9 | | | 2419 | }); |
| bdb18c9 | | | 2420 | |
| bdb18c9 | | | 2421 | input.value = ""; |
| bdb18c9 | | | 2422 | } |
| bdb18c9 | | | 2423 | |
| bdb18c9 | | | 2424 | const noteInputEl = document.getElementById("noteInput"); |
| bdb18c9 | | | 2425 | noteInputEl.addEventListener("keydown", (e) => { |
| bdb18c9 | | | 2426 | if (e.key === "Enter" && !e.shiftKey) { |
| bdb18c9 | | | 2427 | e.preventDefault(); |
| bdb18c9 | | | 2428 | submitNote(); |
| bdb18c9 | | | 2429 | } |
| bdb18c9 | | | 2430 | }); |
| bdb18c9 | | | 2431 | noteInputEl.addEventListener("input", () => { |
| bdb18c9 | | | 2432 | noteInputEl.style.height = "auto"; |
| bdb18c9 | | | 2433 | noteInputEl.style.height = Math.min(noteInputEl.scrollHeight, 80) + "px"; |
| bdb18c9 | | | 2434 | }); |
| bdb18c9 | | | 2435 | |
| be59e71 | | | 2436 | function editNote(noteId, diagramId, card) { |
| bdb18c9 | | | 2437 | const notes = allNotes[diagramId] || []; |
| bdb18c9 | | | 2438 | const note = notes.find(n => n.id === noteId); |
| be59e71 | | | 2439 | if (!note || !card) return; |
| be59e71 | | | 2440 | |
| be59e71 | | | 2441 | const textEl = card.querySelector(".note-text"); |
| be59e71 | | | 2442 | if (!textEl || textEl.dataset.editing) return; |
| be59e71 | | | 2443 | textEl.dataset.editing = "true"; |
| be59e71 | | | 2444 | |
| be59e71 | | | 2445 | const original = note.text; |
| be59e71 | | | 2446 | const input = document.createElement("textarea"); |
| be59e71 | | | 2447 | input.value = original; |
| be59e71 | | | 2448 | input.rows = 2; |
| be59e71 | | | 2449 | input.style.cssText = "width:100%;background:var(--bg-inset);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);padding:4px 8px;font-size:12px;font-family:'Libre Caslon Text',Georgia,serif;resize:none;outline:none;line-height:1.4;"; |
| be59e71 | | | 2450 | input.style.height = "auto"; |
| be59e71 | | | 2451 | |
| be59e71 | | | 2452 | textEl.textContent = ""; |
| be59e71 | | | 2453 | textEl.appendChild(input); |
| be59e71 | | | 2454 | input.focus(); |
| be59e71 | | | 2455 | input.style.height = Math.min(input.scrollHeight, 120) + "px"; |
| be59e71 | | | 2456 | |
| be59e71 | | | 2457 | function commit() { |
| be59e71 | | | 2458 | const newText = input.value.trim(); |
| be59e71 | | | 2459 | delete textEl.dataset.editing; |
| be59e71 | | | 2460 | if (newText && newText !== original) { |
| be59e71 | | | 2461 | socket.emit("edit-note", { noteId, diagramId, text: newText }); |
| be59e71 | | | 2462 | } else { |
| be59e71 | | | 2463 | textEl.textContent = original; |
| be59e71 | | | 2464 | } |
| bdb18c9 | | | 2465 | } |
| be59e71 | | | 2466 | |
| be59e71 | | | 2467 | input.addEventListener("keydown", (e) => { |
| be59e71 | | | 2468 | if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commit(); } |
| be59e71 | | | 2469 | if (e.key === "Escape") { delete textEl.dataset.editing; textEl.textContent = original; } |
| be59e71 | | | 2470 | }); |
| be59e71 | | | 2471 | input.addEventListener("blur", commit); |
| bdb18c9 | | | 2472 | } |
| bdb18c9 | | | 2473 | |
| bdb18c9 | | | 2474 | function deleteNote(noteId, diagramId) { |
| bdb18c9 | | | 2475 | if (!confirm("Delete this note?")) return; |
| bdb18c9 | | | 2476 | socket.emit("delete-note", { noteId, diagramId }); |
| bdb18c9 | | | 2477 | } |
| bdb18c9 | | | 2478 | |
| bdb18c9 | | | 2479 | // ── Actions ── |
| bdb18c9 | | | 2480 | function copyRoomLink() { |
| 2a9592c | | | 2481 | const url = `${window.location.origin}/${encodeURIComponent(currentOwner)}/${encodeURIComponent(currentRepo)}`; |
| bdb18c9 | | | 2482 | navigator.clipboard.writeText(url).then(() => showToast("Link copied!")); |
| bdb18c9 | | | 2483 | } |
| bdb18c9 | | | 2484 | |
| bdb18c9 | | | 2485 | async function exportNotesLLM() { |
| bdb18c9 | | | 2486 | try { |
| 2a9592c | | | 2487 | const headers = {}; |
| 2a9592c | | | 2488 | const token = getCookie("grove_hub_token"); |
| 2a9592c | | | 2489 | if (token) headers["Authorization"] = `Bearer ${token}`; |
| 2a9592c | | | 2490 | const resp = await fetch(`/api/repos/${encodeURIComponent(currentOwner)}/${encodeURIComponent(currentRepo)}/notes/llm`, { headers }); |
| bdb18c9 | | | 2491 | const blob = await resp.blob(); |
| bdb18c9 | | | 2492 | const cd = resp.headers.get("Content-Disposition") || ""; |
| bdb18c9 | | | 2493 | const match = cd.match(/filename="(.+?)"/); |
| 2a9592c | | | 2494 | const filename = match ? match[1] : `collab-notes-${currentOwner}-${currentRepo}.md`; |
| bdb18c9 | | | 2495 | |
| bdb18c9 | | | 2496 | const url = URL.createObjectURL(blob); |
| bdb18c9 | | | 2497 | const a = document.createElement("a"); |
| bdb18c9 | | | 2498 | a.href = url; |
| bdb18c9 | | | 2499 | a.download = filename; |
| bdb18c9 | | | 2500 | document.body.appendChild(a); |
| bdb18c9 | | | 2501 | a.click(); |
| bdb18c9 | | | 2502 | a.remove(); |
| bdb18c9 | | | 2503 | URL.revokeObjectURL(url); |
| bdb18c9 | | | 2504 | showToast("Notes exported!"); |
| bdb18c9 | | | 2505 | } catch (e) { |
| bdb18c9 | | | 2506 | showToast("Export failed"); |
| bdb18c9 | | | 2507 | } |
| bdb18c9 | | | 2508 | } |
| bdb18c9 | | | 2509 | |
| bdb18c9 | | | 2510 | function showToast(msg) { |
| bdb18c9 | | | 2511 | const t = document.getElementById("toast"); |
| bdb18c9 | | | 2512 | t.textContent = msg; |
| bdb18c9 | | | 2513 | t.classList.add("show"); |
| bdb18c9 | | | 2514 | setTimeout(() => t.classList.remove("show"), 2500); |
| bdb18c9 | | | 2515 | } |
| bdb18c9 | | | 2516 | |
| bdb18c9 | | | 2517 | // ── Helpers ── |
| e629dcb | | | 2518 | function setBreadcrumbs(owner, repo) { |
| e629dcb | | | 2519 | const ownerEl = document.getElementById("breadcrumbOwner"); |
| e629dcb | | | 2520 | const repoEl = document.getElementById("breadcrumbRepo"); |
| e629dcb | | | 2521 | if (ownerEl) { |
| e629dcb | | | 2522 | ownerEl.textContent = owner; |
| e629dcb | | | 2523 | ownerEl.href = `/${encodeURIComponent(owner)}`; |
| e629dcb | | | 2524 | } |
| e629dcb | | | 2525 | if (repoEl) repoEl.textContent = repo; |
| e629dcb | | | 2526 | } |
| e629dcb | | | 2527 | |
| e629dcb | | | 2528 | function setupTopbarProfile(user) { |
| e629dcb | | | 2529 | const menu = document.getElementById("topbarProfileMenu"); |
| e629dcb | | | 2530 | const toggle = document.getElementById("topbarProfileToggle"); |
| e629dcb | | | 2531 | const dropdown = document.getElementById("topbarProfileDropdown"); |
| e629dcb | | | 2532 | const nameEl = document.getElementById("topbarProfileName"); |
| bf6031c | | | 2533 | const signInLink = document.getElementById("topbarSignIn"); |
| e629dcb | | | 2534 | if (!menu || !toggle || !user) return; |
| 7f3fce0 | | | 2535 | if (nameEl) nameEl.textContent = user.username; |
| bf6031c | | | 2536 | if (signInLink) signInLink.style.display = "none"; |
| e629dcb | | | 2537 | menu.style.display = ""; |
| e629dcb | | | 2538 | toggle.addEventListener("click", () => dropdown.classList.toggle("open")); |
| e629dcb | | | 2539 | document.addEventListener("mousedown", (e) => { |
| e629dcb | | | 2540 | if (!menu.contains(e.target)) dropdown.classList.remove("open"); |
| e629dcb | | | 2541 | }); |
| e629dcb | | | 2542 | document.getElementById("topbarProfileSignOut").addEventListener("click", (e) => { |
| e629dcb | | | 2543 | e.preventDefault(); |
| e629dcb | | | 2544 | document.cookie = "grove_hub_token=; path=/; max-age=0"; |
| e629dcb | | | 2545 | document.cookie = "grove_hub_user=; path=/; max-age=0"; |
| e629dcb | | | 2546 | const hostname = window.location.hostname; |
| e629dcb | | | 2547 | const parts = hostname.split("."); |
| e629dcb | | | 2548 | const domain = parts.length > 2 ? "." + parts.slice(-2).join(".") : "." + hostname; |
| e629dcb | | | 2549 | document.cookie = "grove_hub_token=; path=/; domain=" + domain + "; max-age=0"; |
| e629dcb | | | 2550 | document.cookie = "grove_hub_user=; path=/; domain=" + domain + "; max-age=0"; |
| e629dcb | | | 2551 | localStorage.removeItem("grove_hub_token"); |
| e629dcb | | | 2552 | localStorage.removeItem("grove_hub_user"); |
| e629dcb | | | 2553 | window.location.href = "/"; |
| e629dcb | | | 2554 | }); |
| e629dcb | | | 2555 | } |
| e629dcb | | | 2556 | |
| bdb18c9 | | | 2557 | function escapeHtml(s) { |
| bdb18c9 | | | 2558 | const d = document.createElement("div"); |
| bdb18c9 | | | 2559 | d.textContent = s; |
| bdb18c9 | | | 2560 | return d.innerHTML; |
| bdb18c9 | | | 2561 | } |
| bdb18c9 | | | 2562 | |
| bdb18c9 | | | 2563 | function formatTime(iso) { |
| bdb18c9 | | | 2564 | try { |
| bdb18c9 | | | 2565 | const d = new Date(iso); |
| bdb18c9 | | | 2566 | return d.toLocaleString("en-CA", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); |
| bdb18c9 | | | 2567 | } catch { return iso; } |
| bdb18c9 | | | 2568 | } |
| bdb18c9 | | | 2569 | |
| bdb18c9 | | | 2570 | // ── Pan / Zoom ── |
| bdb18c9 | | | 2571 | function applyTransform() { |
| bdb18c9 | | | 2572 | const container = document.getElementById("mermaidContainer"); |
| bdb18c9 | | | 2573 | container.style.transform = `translate(${panZoom.x}px, ${panZoom.y}px) scale(${panZoom.scale})`; |
| bdb18c9 | | | 2574 | document.getElementById("zoomLevel").textContent = Math.round(panZoom.scale * 100) + "%"; |
| bf1f995 | | | 2575 | repositionRemoteCursors(); |
| bdb18c9 | | | 2576 | } |
| bdb18c9 | | | 2577 | |
| bdb18c9 | | | 2578 | function zoomIn() { |
| bdb18c9 | | | 2579 | panZoom.scale = Math.min(panZoom.scale * 1.25, 10); |
| bdb18c9 | | | 2580 | applyTransform(); |
| bdb18c9 | | | 2581 | } |
| bdb18c9 | | | 2582 | |
| bdb18c9 | | | 2583 | function zoomOut() { |
| bdb18c9 | | | 2584 | panZoom.scale = Math.max(panZoom.scale / 1.25, 0.1); |
| bdb18c9 | | | 2585 | applyTransform(); |
| bdb18c9 | | | 2586 | } |
| bdb18c9 | | | 2587 | |
| bdb18c9 | | | 2588 | function zoomReset() { |
| bdb18c9 | | | 2589 | const pane = document.getElementById("diagramPane"); |
| bdb18c9 | | | 2590 | const container = document.getElementById("mermaidContainer"); |
| bdb18c9 | | | 2591 | // Reset to measure natural size |
| bdb18c9 | | | 2592 | container.style.transform = "none"; |
| bdb18c9 | | | 2593 | const cw = container.scrollWidth; |
| bdb18c9 | | | 2594 | const ch = container.scrollHeight; |
| bdb18c9 | | | 2595 | const pw = pane.clientWidth; |
| bdb18c9 | | | 2596 | const ph = pane.clientHeight; |
| bdb18c9 | | | 2597 | // Start at 100% (natural size), centered in the pane |
| bdb18c9 | | | 2598 | panZoom.scale = 1; |
| bdb18c9 | | | 2599 | panZoom.x = (pw - cw) / 2; |
| bdb18c9 | | | 2600 | panZoom.y = (ph - ch) / 2; |
| bdb18c9 | | | 2601 | applyTransform(); |
| bdb18c9 | | | 2602 | } |
| bdb18c9 | | | 2603 | |
| bdb18c9 | | | 2604 | // Wheel zoom (pinch-to-zoom on trackpad) |
| bdb18c9 | | | 2605 | document.getElementById("diagramPane").addEventListener("wheel", (e) => { |
| bdb18c9 | | | 2606 | e.preventDefault(); |
| bdb18c9 | | | 2607 | const pane = document.getElementById("diagramPane"); |
| bdb18c9 | | | 2608 | const rect = pane.getBoundingClientRect(); |
| bdb18c9 | | | 2609 | |
| bdb18c9 | | | 2610 | // Mouse position relative to pane |
| bdb18c9 | | | 2611 | const mx = e.clientX - rect.left; |
| bdb18c9 | | | 2612 | const my = e.clientY - rect.top; |
| bdb18c9 | | | 2613 | |
| bdb18c9 | | | 2614 | // Point in content space before zoom |
| bdb18c9 | | | 2615 | const contentX = (mx - panZoom.x) / panZoom.scale; |
| bdb18c9 | | | 2616 | const contentY = (my - panZoom.y) / panZoom.scale; |
| bdb18c9 | | | 2617 | |
| bdb18c9 | | | 2618 | // Determine zoom delta |
| bdb18c9 | | | 2619 | const delta = e.deltaY > 0 ? 0.9 : 1.1; |
| bdb18c9 | | | 2620 | panZoom.scale = Math.min(Math.max(panZoom.scale * delta, 0.1), 10); |
| bdb18c9 | | | 2621 | |
| bdb18c9 | | | 2622 | // Adjust pan so the point under the cursor stays fixed |
| bdb18c9 | | | 2623 | panZoom.x = mx - contentX * panZoom.scale; |
| bdb18c9 | | | 2624 | panZoom.y = my - contentY * panZoom.scale; |
| bdb18c9 | | | 2625 | |
| bdb18c9 | | | 2626 | applyTransform(); |
| bdb18c9 | | | 2627 | }, { passive: false }); |
| bdb18c9 | | | 2628 | |
| bdb18c9 | | | 2629 | // Mouse drag to pan |
| bdb18c9 | | | 2630 | document.getElementById("diagramPane").addEventListener("mousedown", (e) => { |
| cee484a | | | 2631 | const onInteractive = e.target.closest(".bottom-toolbar, .node-clickable"); |
| cee484a | | | 2632 | // Middle-click always pans; left-click pans only outside interactive elements |
| bdb18c9 | | | 2633 | if (e.button === 1 || (e.button === 0 && !onInteractive)) { |
| bdb18c9 | | | 2634 | isPanning = true; |
| cee484a | | | 2635 | didDrag = false; |
| bdb18c9 | | | 2636 | panStart = { x: e.clientX, y: e.clientY }; |
| bdb18c9 | | | 2637 | panStartOffset = { x: panZoom.x, y: panZoom.y }; |
| bdb18c9 | | | 2638 | document.getElementById("diagramPane").classList.add("panning"); |
| bdb18c9 | | | 2639 | e.preventDefault(); |
| bdb18c9 | | | 2640 | } |
| bdb18c9 | | | 2641 | }); |
| bdb18c9 | | | 2642 | |
| bdb18c9 | | | 2643 | window.addEventListener("mousemove", (e) => { |
| bdb18c9 | | | 2644 | if (!isPanning) return; |
| cee484a | | | 2645 | const dx = e.clientX - panStart.x; |
| cee484a | | | 2646 | const dy = e.clientY - panStart.y; |
| cee484a | | | 2647 | if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didDrag = true; |
| cee484a | | | 2648 | panZoom.x = panStartOffset.x + dx; |
| cee484a | | | 2649 | panZoom.y = panStartOffset.y + dy; |
| bdb18c9 | | | 2650 | applyTransform(); |
| bdb18c9 | | | 2651 | }); |
| bdb18c9 | | | 2652 | |
| bdb18c9 | | | 2653 | window.addEventListener("mouseup", (e) => { |
| bdb18c9 | | | 2654 | if (isPanning) { |
| bdb18c9 | | | 2655 | isPanning = false; |
| bdb18c9 | | | 2656 | document.getElementById("diagramPane").classList.remove("panning"); |
| bdb18c9 | | | 2657 | } |
| bdb18c9 | | | 2658 | }); |
| bdb18c9 | | | 2659 | |
| cee484a | | | 2660 | // Click on diagram background clears node focus |
| cee484a | | | 2661 | // (node clicks call stopPropagation, so only background clicks reach here) |
| cee484a | | | 2662 | document.getElementById("diagramPane").addEventListener("click", (e) => { |
| cee484a | | | 2663 | if (didDrag || !selectedNodeId) return; |
| cee484a | | | 2664 | const onToolbar = e.target.closest(".bottom-toolbar"); |
| cee484a | | | 2665 | if (!onToolbar) clearNodeFocus(); |
| cee484a | | | 2666 | }); |
| cee484a | | | 2667 | |
| 922dd18 | | | 2668 | // ── Homepage ── |
| 922dd18 | | | 2669 | function showHomepage(token, user) { |
| 922dd18 | | | 2670 | // Hide the collab UI, show the homepage |
| 922dd18 | | | 2671 | document.body.innerHTML = ""; |
| 922dd18 | | | 2672 | document.body.style.overflow = "auto"; |
| 922dd18 | | | 2673 | document.body.style.height = "auto"; |
| 922dd18 | | | 2674 | |
| a3cd4f5 | | | 2675 | // Nav bar (matches Grove) |
| a3cd4f5 | | | 2676 | const nav = document.createElement("nav"); |
| a3cd4f5 | | | 2677 | nav.className = "homepage-nav"; |
| a3cd4f5 | | | 2678 | nav.innerHTML = ` |
| a3cd4f5 | | | 2679 | <a href="/" class="nav-left" style="text-decoration:none"> |
| bf6031c | | | 2680 | <svg width="28" height="28" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="var(--accent)"/><path d="M9 16 C9 11, 14 8, 21 8 L35 8 C42 8, 47 11, 47 16 L47 24 C47 29, 42 32, 35 32 L19 32 L13 37 L13 32 C10 31, 9 29, 9 24 Z" fill="white"/><path d="M55 35 C55 30, 50 27, 43 27 L29 27 C22 27, 17 30, 17 35 L17 43 C17 48, 22 51, 29 51 L45 51 L51 56 L51 51 C54 50, 55 48, 55 43 Z" fill="white" opacity="0.65"/></svg> |
| a3cd4f5 | | | 2681 | </a> |
| a3cd4f5 | | | 2682 | <div class="nav-right"> |
| 792cc2e | | | 2683 | ${user ? ` |
| 792cc2e | | | 2684 | <div class="profile-menu"> |
| 7f3fce0 | | | 2685 | <button class="profile-pill" id="profileToggle"> |
| 7f3fce0 | | | 2686 | ${escapeHtml(user.username)} |
| 7f3fce0 | | | 2687 | <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4L5 6.5L7.5 4" /></svg> |
| 792cc2e | | | 2688 | </button> |
| 792cc2e | | | 2689 | <div class="profile-dropdown" id="profileDropdown"> |
| 792cc2e | | | 2690 | <a href="/dashboard">Dashboard</a> |
| 792cc2e | | | 2691 | <a href="#" id="profileSignOut">Sign out</a> |
| 792cc2e | | | 2692 | </div> |
| 792cc2e | | | 2693 | </div> |
| 792cc2e | | | 2694 | ` : `<a href="/login">Sign in</a>`} |
| a3cd4f5 | | | 2695 | </div> |
| a3cd4f5 | | | 2696 | `; |
| a3cd4f5 | | | 2697 | document.body.appendChild(nav); |
| a3cd4f5 | | | 2698 | |
| 792cc2e | | | 2699 | // Profile dropdown toggle |
| 792cc2e | | | 2700 | const profileToggle = document.getElementById("profileToggle"); |
| 792cc2e | | | 2701 | const profileDropdown = document.getElementById("profileDropdown"); |
| 792cc2e | | | 2702 | if (profileToggle && profileDropdown) { |
| 792cc2e | | | 2703 | profileToggle.addEventListener("click", () => { |
| 792cc2e | | | 2704 | profileDropdown.classList.toggle("open"); |
| 792cc2e | | | 2705 | }); |
| 792cc2e | | | 2706 | document.addEventListener("mousedown", (e) => { |
| 792cc2e | | | 2707 | if (!profileToggle.contains(e.target) && !profileDropdown.contains(e.target)) { |
| 792cc2e | | | 2708 | profileDropdown.classList.remove("open"); |
| 792cc2e | | | 2709 | } |
| 792cc2e | | | 2710 | }); |
| 792cc2e | | | 2711 | document.getElementById("profileSignOut").addEventListener("click", (e) => { |
| 792cc2e | | | 2712 | e.preventDefault(); |
| 792cc2e | | | 2713 | document.cookie = "grove_hub_token=; path=/; max-age=0"; |
| 792cc2e | | | 2714 | document.cookie = "grove_hub_user=; path=/; max-age=0"; |
| 792cc2e | | | 2715 | const hostname = window.location.hostname; |
| 792cc2e | | | 2716 | const parts = hostname.split("."); |
| 792cc2e | | | 2717 | const domain = parts.length > 2 ? "." + parts.slice(-2).join(".") : "." + hostname; |
| 792cc2e | | | 2718 | document.cookie = "grove_hub_token=; path=/; domain=" + domain + "; max-age=0"; |
| 792cc2e | | | 2719 | document.cookie = "grove_hub_user=; path=/; domain=" + domain + "; max-age=0"; |
| 792cc2e | | | 2720 | localStorage.removeItem("grove_hub_token"); |
| 792cc2e | | | 2721 | localStorage.removeItem("grove_hub_user"); |
| 792cc2e | | | 2722 | window.location.href = "/"; |
| 792cc2e | | | 2723 | }); |
| 792cc2e | | | 2724 | } |
| 792cc2e | | | 2725 | |
| 922dd18 | | | 2726 | const root = document.createElement("div"); |
| 922dd18 | | | 2727 | root.className = "homepage"; |
| 922dd18 | | | 2728 | |
| 922dd18 | | | 2729 | root.innerHTML = ` |
| 922dd18 | | | 2730 | <div class="homepage-search"> |
| 922dd18 | | | 2731 | <input id="homepageSearch" type="text" placeholder="Search repositories" /> |
| 922dd18 | | | 2732 | </div> |
| 922dd18 | | | 2733 | <div id="homepageContent"> |
| 922dd18 | | | 2734 | <div class="homepage-loading"> |
| 922dd18 | | | 2735 | ${[0,1,2,3].map(i => `<div class="skel-row"><div class="skel" style="width:${[240,200,260,220][i]}px;margin-bottom:4px"></div><div class="skel" style="width:${[180,160,200,140][i]}px;height:0.7rem"></div></div>`).join("")} |
| 922dd18 | | | 2736 | </div> |
| 922dd18 | | | 2737 | </div> |
| 922dd18 | | | 2738 | `; |
| 922dd18 | | | 2739 | document.body.appendChild(root); |
| 922dd18 | | | 2740 | |
| 922dd18 | | | 2741 | let allRepos = []; |
| 922dd18 | | | 2742 | |
| 922dd18 | | | 2743 | function timeAgo(ts) { |
| 922dd18 | | | 2744 | const secs = Math.floor(Date.now() / 1000) - ts; |
| 922dd18 | | | 2745 | if (secs < 60) return "just now"; |
| 922dd18 | | | 2746 | if (secs < 3600) return Math.floor(secs / 60) + "m ago"; |
| 922dd18 | | | 2747 | if (secs < 86400) return Math.floor(secs / 3600) + "h ago"; |
| 922dd18 | | | 2748 | if (secs < 2592000) return Math.floor(secs / 86400) + "d ago"; |
| 922dd18 | | | 2749 | return Math.floor(secs / 2592000) + "mo ago"; |
| 922dd18 | | | 2750 | } |
| 922dd18 | | | 2751 | |
| 922dd18 | | | 2752 | function renderRepos(repos) { |
| 922dd18 | | | 2753 | const container = document.getElementById("homepageContent"); |
| 922dd18 | | | 2754 | if (repos.length === 0) { |
| 922dd18 | | | 2755 | container.innerHTML = ` |
| 922dd18 | | | 2756 | <div class="homepage-empty"> |
| 922dd18 | | | 2757 | <div class="logo-wrap"> |
| bf6031c | | | 2758 | <svg width="48" height="48" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="var(--accent)"/><path d="M9 16 C9 11, 14 8, 21 8 L35 8 C42 8, 47 11, 47 16 L47 24 C47 29, 42 32, 35 32 L19 32 L13 37 L13 32 C10 31, 9 29, 9 24 Z" fill="white"/><path d="M55 35 C55 30, 50 27, 43 27 L29 27 C22 27, 17 30, 17 35 L17 43 C17 48, 22 51, 29 51 L45 51 L51 56 L51 51 C54 50, 55 48, 55 43 Z" fill="white" opacity="0.65"/></svg> |
| 922dd18 | | | 2759 | </div> |
| 922dd18 | | | 2760 | <p>No repositories found.</p> |
| 922dd18 | | | 2761 | </div> |
| 922dd18 | | | 2762 | `; |
| 922dd18 | | | 2763 | return; |
| 922dd18 | | | 2764 | } |
| 922dd18 | | | 2765 | |
| 922dd18 | | | 2766 | // Group by owner |
| 922dd18 | | | 2767 | const groups = []; |
| 922dd18 | | | 2768 | const map = new Map(); |
| 922dd18 | | | 2769 | for (const r of repos) { |
| 922dd18 | | | 2770 | const owner = r.owner_name; |
| 922dd18 | | | 2771 | if (!map.has(owner)) { map.set(owner, []); groups.push({ owner, repos: [] }); } |
| 922dd18 | | | 2772 | groups.find(g => g.owner === owner).repos.push(r); |
| 922dd18 | | | 2773 | } |
| 922dd18 | | | 2774 | |
| 922dd18 | | | 2775 | container.innerHTML = groups.map(g => ` |
| 922dd18 | | | 2776 | <div class="homepage-group"> |
| 922dd18 | | | 2777 | <span class="homepage-owner">${escapeHtml(g.owner)}</span> |
| 922dd18 | | | 2778 | ${g.repos.map(r => { |
| 922dd18 | | | 2779 | const commitTs = r.last_commit_ts ?? null; |
| 922dd18 | | | 2780 | const updatedTs = r.updated_at ? Math.floor(new Date(r.updated_at).getTime() / 1000) : null; |
| 922dd18 | | | 2781 | const ts = commitTs ?? updatedTs; |
| 922dd18 | | | 2782 | const label = commitTs ? "Pushed" : updatedTs ? "Updated" : null; |
| 922dd18 | | | 2783 | return ` |
| 922dd18 | | | 2784 | <a href="/${encodeURIComponent(r.owner_name)}/${encodeURIComponent(r.name)}" class="homepage-repo"> |
| 922dd18 | | | 2785 | <div style="min-width:0"> |
| 922dd18 | | | 2786 | <div class="homepage-repo-name">${escapeHtml(r.name)}</div> |
| 922dd18 | | | 2787 | <div class="homepage-repo-desc">${escapeHtml(r.description || "No description")}</div> |
| 922dd18 | | | 2788 | </div> |
| 922dd18 | | | 2789 | <span class="homepage-repo-time">${ts ? `${label} ${timeAgo(ts)}` : ""}</span> |
| 922dd18 | | | 2790 | </a> |
| 922dd18 | | | 2791 | `; |
| 922dd18 | | | 2792 | }).join("")} |
| 922dd18 | | | 2793 | </div> |
| 922dd18 | | | 2794 | `).join(""); |
| 922dd18 | | | 2795 | } |
| 922dd18 | | | 2796 | |
| 922dd18 | | | 2797 | // Fetch repos |
| 515cc68 | | | 2798 | const repoHeaders = token ? { Authorization: `Bearer ${token}` } : {}; |
| 515cc68 | | | 2799 | fetch("/api/repos", { headers: repoHeaders }) |
| 922dd18 | | | 2800 | .then(res => res.json()) |
| 922dd18 | | | 2801 | .then(data => { |
| 922dd18 | | | 2802 | allRepos = (data.repos || []).sort((a, b) => { |
| 922dd18 | | | 2803 | const aTs = a.last_commit_ts ?? (a.updated_at ? Math.floor(new Date(a.updated_at).getTime() / 1000) : 0); |
| 922dd18 | | | 2804 | const bTs = b.last_commit_ts ?? (b.updated_at ? Math.floor(new Date(b.updated_at).getTime() / 1000) : 0); |
| 922dd18 | | | 2805 | return bTs - aTs; |
| 922dd18 | | | 2806 | }); |
| 922dd18 | | | 2807 | renderRepos(allRepos); |
| 922dd18 | | | 2808 | }) |
| 922dd18 | | | 2809 | .catch(() => renderRepos([])); |
| 922dd18 | | | 2810 | |
| 922dd18 | | | 2811 | // Search |
| 922dd18 | | | 2812 | document.getElementById("homepageSearch").addEventListener("input", (e) => { |
| 922dd18 | | | 2813 | const q = e.target.value.trim().toLowerCase(); |
| 922dd18 | | | 2814 | if (!q) { renderRepos(allRepos); return; } |
| 922dd18 | | | 2815 | renderRepos(allRepos.filter(r => { |
| 922dd18 | | | 2816 | return `${r.owner_name} ${r.name} ${r.description || ""}`.toLowerCase().includes(q); |
| 922dd18 | | | 2817 | })); |
| 922dd18 | | | 2818 | }); |
| 922dd18 | | | 2819 | } |
| 922dd18 | | | 2820 | |
| 515cc68 | | | 2821 | // ── Init: load page, connect socket only if authenticated ── |
| 1e755c0 | | | 2822 | (async function() { |
| 2a9592c | | | 2823 | const token = getCookie("grove_hub_token"); |
| 2a9592c | | | 2824 | const userJson = getCookie("grove_hub_user"); |
| 515cc68 | | | 2825 | let user = null; |
| 515cc68 | | | 2826 | if (token && userJson) { |
| 515cc68 | | | 2827 | try { user = JSON.parse(userJson); } catch { /* treat as unauthenticated */ } |
| 2a9592c | | | 2828 | } |
| 2a9592c | | | 2829 | |
| 2a9592c | | | 2830 | // Extract owner/repo from URL path |
| 2a9592c | | | 2831 | const pathParts = window.location.pathname.split("/").filter(Boolean); |
| 2a9592c | | | 2832 | if (pathParts.length < 2) { |
| 922dd18 | | | 2833 | showHomepage(token, user); |
| 2a9592c | | | 2834 | return; |
| 2a9592c | | | 2835 | } |
| 2a9592c | | | 2836 | const [owner, repo] = pathParts; |
| 2a9592c | | | 2837 | |
| e629dcb | | | 2838 | // Set up topbar profile icon |
| e629dcb | | | 2839 | setupTopbarProfile(user); |
| e629dcb | | | 2840 | |
| 515cc68 | | | 2841 | if (token && user) { |
| 515cc68 | | | 2842 | await joinRoom(owner, repo, token, user); |
| 515cc68 | | | 2843 | } else { |
| 515cc68 | | | 2844 | // Unauthenticated: load diagrams read-only (no real-time collab) |
| 515cc68 | | | 2845 | currentOwner = owner; |
| 515cc68 | | | 2846 | currentRepo = repo; |
| 515cc68 | | | 2847 | myName = "Anonymous"; |
| 515cc68 | | | 2848 | roomId = `${owner}/${repo}`; |
| e629dcb | | | 2849 | setBreadcrumbs(owner, repo); |
| 515cc68 | | | 2850 | await loadDiagramsFromServer(owner, repo); |
| 515cc68 | | | 2851 | buildDrawer(); |
| 515cc68 | | | 2852 | switchTab(0); |
| 515cc68 | | | 2853 | } |
| bdb18c9 | | | 2854 | })(); |
| bdb18c9 | | | 2855 | |
| bdb18c9 | | | 2856 | // Live reload: reconnect a lightweight socket just for hot-reload signals |
| bdb18c9 | | | 2857 | (function() { |
| bdb18c9 | | | 2858 | const reloadSocket = io(); |
| bdb18c9 | | | 2859 | reloadSocket.on("hot-reload", ({ file }) => { |
| bdb18c9 | | | 2860 | console.log(`[hot-reload] ${file} changed, reloading...`); |
| bdb18c9 | | | 2861 | window.location.reload(); |
| bdb18c9 | | | 2862 | }); |
| bdb18c9 | | | 2863 | })(); |
| bdb18c9 | | | 2864 | </script> |
| bdb18c9 | | | 2865 | </body> |
| bdb18c9 | | | 2866 | </html> |