Browse Source

feat(canvas): Add edit mode, mouse toggle, and h1 spacing fix

- Press 'e' to edit in system default app (Notepads, VS Code, etc.)
- Press 'm' to toggle mouse capture (off = text selection works)
- Add blank line after h1 heading for better visual spacing
- Cross-platform editor: $VISUAL > $EDITOR > system default

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
0xDarkMatter 3 months ago
parent
commit
638ddd0a8d
4 changed files with 126 additions and 13 deletions
  1. 4 4
      canvas-tui/package.json
  2. 53 8
      canvas-tui/src/app.tsx
  3. 18 1
      canvas-tui/src/hooks/useMarkdown.ts
  4. 51 0
      canvas-tui/src/lib/editor.ts

+ 4 - 4
canvas-tui/package.json

@@ -34,14 +34,14 @@
     "node": ">=18.0.0"
   },
   "dependencies": {
-    "ink": "^5.0.1",
     "@inkjs/ui": "^2.0.0",
-    "react": "^18.3.1",
+    "chalk": "^5.3.0",
     "chokidar": "^4.0.3",
+    "ink": "^5.0.1",
     "marked": "^15.0.4",
     "marked-terminal": "^7.2.1",
-    "chalk": "^5.3.0",
-    "meow": "^13.2.0"
+    "meow": "^13.2.0",
+    "react": "^18.3.1"
   },
   "devDependencies": {
     "@types/node": "^22.10.5",

+ 53 - 8
canvas-tui/src/app.tsx

@@ -5,6 +5,7 @@ import { MarkdownView } from './components/MarkdownView.js';
 import { StatusBar } from './components/StatusBar.js';
 import { useFileWatcher } from './hooks/useFileWatcher.js';
 import { readMeta, type CanvasMeta } from './lib/ipc.js';
+import { editInExternalEditor } from './lib/editor.js';
 
 interface AppProps {
   watchPath: string;
@@ -25,6 +26,8 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
   const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
   const [scrollOffset, setScrollOffset] = useState(0);
   const [totalLines, setTotalLines] = useState(0);
+  const [mouseEnabled, setMouseEnabled] = useState(enableMouse);
+  const [isEditing, setIsEditing] = useState(false);
 
   // Terminal dimensions
   const rows = stdout?.rows || 24;
@@ -36,7 +39,7 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
 
   // Enable mouse tracking
   useLayoutEffect(() => {
-    if (enableMouse) {
+    if (mouseEnabled) {
       process.stdout.write(ENABLE_MOUSE);
 
       // Listen for mouse events on stdin
@@ -65,7 +68,7 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
         process.stdin.off('data', handleData);
       };
     }
-  }, [enableMouse, totalLines, contentHeight]);
+  }, [mouseEnabled, totalLines, contentHeight]);
 
   // Update content when file changes
   useEffect(() => {
@@ -83,12 +86,13 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
   // Keyboard input
   useInput((input, key) => {
     if (input === 'q' || (key.ctrl && input === 'c')) {
-      if (enableMouse) {
+      if (mouseEnabled) {
         process.stdout.write(DISABLE_MOUSE);
       }
       exit();
     }
 
+    // Scrolling
     if (key.upArrow) {
       setScrollOffset(prev => Math.max(0, prev - 1));
     }
@@ -101,15 +105,55 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
     if (key.pageDown) {
       setScrollOffset(prev => Math.min(Math.max(0, totalLines - contentHeight), prev + contentHeight));
     }
-    if (input === 'g') {
+
+    // Home/End and vim-style navigation
+    if (input === 'g' || key.meta && key.upArrow) {
       setScrollOffset(0);
     }
-    if (input === 'G') {
+    if (input === 'G' || key.meta && key.downArrow) {
       setScrollOffset(Math.max(0, totalLines - contentHeight));
     }
+
+    // Refresh
     if (input === 'r') {
       setSyncStatus('watching');
     }
+
+    // Toggle mouse capture
+    if (input === 'm') {
+      setMouseEnabled(prev => {
+        const newValue = !prev;
+        if (newValue) {
+          process.stdout.write(ENABLE_MOUSE);
+        } else {
+          process.stdout.write(DISABLE_MOUSE);
+        }
+        return newValue;
+      });
+    }
+
+    // Edit in external editor
+    if (input === 'e' && !isEditing && content) {
+      setIsEditing(true);
+      // Disable mouse while editing
+      if (mouseEnabled) {
+        process.stdout.write(DISABLE_MOUSE);
+      }
+      editInExternalEditor(watchPath)
+        .then(() => {
+          setIsEditing(false);
+          // Re-enable mouse if it was on
+          if (mouseEnabled) {
+            process.stdout.write(ENABLE_MOUSE);
+          }
+        })
+        .catch(() => {
+          setIsEditing(false);
+          if (mouseEnabled) {
+            process.stdout.write(ENABLE_MOUSE);
+          }
+        });
+    }
   });
 
   // Status message
@@ -123,9 +167,10 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
     ? ` | ${scrollOffset + 1}-${Math.min(scrollOffset + contentHeight, totalLines)}/${totalLines}`
     : '';
 
-  const hints = enableMouse
-    ? `q: quit | scroll: arrows/mouse${scrollHint}`
-    : `q: quit | scroll: arrows${scrollHint}`;
+  const mouseHint = mouseEnabled ? 'mouse:on' : 'mouse:off';
+  const hints = isEditing
+    ? 'Editing... save & quit editor to return'
+    : `q:quit | e:edit | m:${mouseHint} | arrows${scrollHint}`;
 
   return (
     <Box flexDirection="column" height={rows}>

+ 18 - 1
canvas-tui/src/hooks/useMarkdown.ts

@@ -63,7 +63,24 @@ export function useMarkdown(content: string): string {
       const rendered = marked.parse(content);
       // marked returns Promise in some configs, but sync with markedTerminal
       if (typeof rendered === 'string') {
-        return rendered.trim();
+        // Ensure proper spacing after headings
+        // ANSI underline is \x1B[4m, reset includes \x1B[0m or \x1B[24m
+        const lines = rendered.trim().split('\n');
+        const result: string[] = [];
+
+        for (let i = 0; i < lines.length; i++) {
+          result.push(lines[i]);
+
+          // If this line contains underline codes (h1) or bold (other headings)
+          // and next line exists and is not empty, add a blank line
+          const hasUnderline = lines[i].includes('\x1B[4m');
+          const nextLine = lines[i + 1];
+          if (hasUnderline && nextLine !== undefined && nextLine.trim() !== '') {
+            result.push('');
+          }
+        }
+
+        return result.join('\n');
       }
       return content; // Fallback to raw content
     } catch (err) {

+ 51 - 0
canvas-tui/src/lib/editor.ts

@@ -0,0 +1,51 @@
+import { spawn } from 'child_process';
+import path from 'path';
+
+/**
+ * Opens the file in the user's preferred editor and waits for them to close it.
+ * Checks $VISUAL, $EDITOR, then falls back to platform defaults.
+ */
+export async function editInExternalEditor(filePath: string): Promise<void> {
+  const { cmd, args } = getEditor();
+  const absolutePath = path.resolve(filePath);
+
+  return new Promise((resolve, reject) => {
+    const child = spawn(cmd, [...args, absolutePath], {
+      stdio: 'inherit',
+      shell: true,
+    });
+
+    child.on('error', (err) => {
+      reject(new Error(`Failed to open editor: ${err.message}`));
+    });
+
+    child.on('close', (code) => {
+      if (code === 0) {
+        resolve();
+      } else {
+        reject(new Error(`Editor exited with code ${code}`));
+      }
+    });
+  });
+}
+
+/**
+ * Get the editor/opener command to use.
+ * Priority: $VISUAL > $EDITOR > platform default (opens with associated app)
+ */
+function getEditor(): { cmd: string; args: string[] } {
+  if (process.env.VISUAL) return { cmd: process.env.VISUAL, args: [] };
+  if (process.env.EDITOR) return { cmd: process.env.EDITOR, args: [] };
+
+  // Platform defaults - open with associated application
+  if (process.platform === 'win32') {
+    // 'start' opens with default app, '' is window title, /wait makes it blocking
+    return { cmd: 'start', args: ['""', '/wait'] };
+  }
+  if (process.platform === 'darwin') {
+    // macOS: open -W waits for app to close
+    return { cmd: 'open', args: ['-W'] };
+  }
+  // Linux: xdg-open (doesn't wait, but best we can do)
+  return { cmd: 'xdg-open', args: [] };
+}