Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ GitHub Actions (`.github/workflows/ci.yml`): pushes and PRs to `main` run unit t

See `docs/DEPLOYMENT.md` for full deployment instructions. The frontend builds to `dist/` and can be served by any static file server. The signaling server runs as a Node.js process. Services can be managed with systemd or PM2. Health check: `curl http://localhost:3001/health`.

## Commit Rules

- Do NOT include `Co-Authored-By` lines in commit messages. Commits should only show the git-configured user as author.

## Environment Variables

- `PORT` (server, default 3001) — signaling server port
Expand Down
63 changes: 61 additions & 2 deletions src/components/CodeEditor/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useCallback, useEffect, ChangeEvent } from 'react';
import { useRef, useCallback, useEffect, useLayoutEffect, ChangeEvent, KeyboardEvent } from 'react';
import Prism from 'prismjs';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-python';
Expand All @@ -17,7 +17,7 @@ import 'prismjs/components/prism-markup-templating';
import 'prismjs/components/prism-php';
import 'prismjs/components/prism-sql';
import { useEditorStore } from '../../stores/editorStore';
import { getPrismLanguage } from '../../services/code-editor-logic';
import { getPrismLanguage, dedentLines, getLeadingWhitespace } from '../../services/code-editor-logic';
import { calculateTextOperation } from '../../services/ot-engine';
import RemoteCursors from './RemoteCursors';

Expand All @@ -30,6 +30,27 @@ export default function CodeEditor() {
const inputRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLPreElement>(null);
const prevCodeRef = useRef(code);
const pendingCursorRef = useRef<{ start: number; end: number } | null>(null);

// Set cursor position after React re-renders (runs before browser paint)
useLayoutEffect(() => {
if (pendingCursorRef.current && inputRef.current) {
inputRef.current.selectionStart = pendingCursorRef.current.start;
inputRef.current.selectionEnd = pendingCursorRef.current.end;
pendingCursorRef.current = null;
}
});

const applyEdit = useCallback(
(oldCode: string, newCode: string, cursorStart: number, cursorEnd: number) => {
calculateTextOperation(oldCode, newCode);
applyLocalOperation();
setCode(newCode);
prevCodeRef.current = newCode;
pendingCursorRef.current = { start: cursorStart, end: cursorEnd };
},
[setCode, applyLocalOperation],
);

const handleInput = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
Expand All @@ -46,6 +67,43 @@ export default function CodeEditor() {
[setCode, applyLocalOperation],
);

const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
const textarea = inputRef.current;
if (!textarea) return;

const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const oldCode = textarea.value;

if (e.key === 'Tab') {
e.preventDefault();

if (e.shiftKey) {
// Shift+Tab: dedent selected lines
const { text: newCode, newStart, newEnd } = dedentLines(oldCode, start, end);
if (newCode !== oldCode) {
applyEdit(oldCode, newCode, newStart, newEnd);
}
} else {
// Tab: insert 4 spaces
const spaces = ' ';
const newCode = oldCode.substring(0, start) + spaces + oldCode.substring(end);
applyEdit(oldCode, newCode, start + spaces.length, start + spaces.length);
}
} else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
// Enter: auto-indent — new line starts at same indentation as current line
e.preventDefault();
const indent = getLeadingWhitespace(oldCode, start);
const insertion = '\n' + indent;
const newCode = oldCode.substring(0, start) + insertion + oldCode.substring(end);
const newCursor = start + insertion.length;
applyEdit(oldCode, newCode, newCursor, newCursor);
}
},
[applyEdit],
);

// Keep prevCodeRef in sync with external code changes (remote operations)
useEffect(() => {
prevCodeRef.current = code;
Expand Down Expand Up @@ -73,6 +131,7 @@ export default function CodeEditor() {
ref={inputRef}
value={code}
onChange={handleInput}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
spellCheck={false}
autoCapitalize="off"
Expand Down
10 changes: 10 additions & 0 deletions src/components/DiagramCanvas/CanvasToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ interface ToolDefinition {
}

const TOOLS: ToolDefinition[] = [
{
id: 'select',
title: 'Select / Move',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z" />
<path d="M13 13l6 6" />
</svg>
),
},
{
id: 'pen',
title: 'Pen',
Expand Down
Loading