[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
383
components/notes/NoteSourceEditor.tsx
Normal file
383
components/notes/NoteSourceEditor.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import { type MarkdownActionType, wrapMarkdownSyntax } from "../../domain/notes";
|
||||
|
||||
const SOURCE_EDIT_UNDO_COALESCE_MS = 750;
|
||||
|
||||
interface SourceHistorySnapshot {
|
||||
value: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
export const shouldCoalesceSourceUndoStep = (
|
||||
previous: { inputType: string; at: number; caret: number } | null,
|
||||
inputType: string,
|
||||
now: number,
|
||||
edit: { start: number; removedLength: number; insertedLength: number },
|
||||
): boolean => {
|
||||
if (!previous || previous.inputType !== inputType || now - previous.at > SOURCE_EDIT_UNDO_COALESCE_MS) {
|
||||
return false;
|
||||
}
|
||||
if (inputType === "insertText") {
|
||||
return edit.removedLength === 0 && edit.start === previous.caret;
|
||||
}
|
||||
if (inputType === "deleteContentBackward") {
|
||||
return edit.insertedLength === 0 && edit.start + edit.removedLength === previous.caret;
|
||||
}
|
||||
if (inputType === "deleteContentForward") {
|
||||
return edit.insertedLength === 0 && edit.start === previous.caret;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getSourceEditDelta = (
|
||||
previousValue: string,
|
||||
nextValue: string,
|
||||
): { start: number; removedLength: number; insertedLength: number } => {
|
||||
let start = 0;
|
||||
const sharedLength = Math.min(previousValue.length, nextValue.length);
|
||||
while (start < sharedLength && previousValue[start] === nextValue[start]) start += 1;
|
||||
|
||||
let previousEnd = previousValue.length;
|
||||
let nextEnd = nextValue.length;
|
||||
while (
|
||||
previousEnd > start
|
||||
&& nextEnd > start
|
||||
&& previousValue[previousEnd - 1] === nextValue[nextEnd - 1]
|
||||
) {
|
||||
previousEnd -= 1;
|
||||
nextEnd -= 1;
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
removedLength: previousEnd - start,
|
||||
insertedLength: nextEnd - start,
|
||||
};
|
||||
};
|
||||
|
||||
export interface NoteSourceEditorHandle {
|
||||
insertAction: (action: MarkdownActionType) => void;
|
||||
focus: () => void;
|
||||
scrollToLine: (line: number) => boolean;
|
||||
}
|
||||
|
||||
export interface NoteSourceEditorProps {
|
||||
noteId?: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
noteFontFamily?: string;
|
||||
noteFontSize?: number;
|
||||
/** Blocks edits (used when the rich editor cannot render the markdown). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSourceEditorProps>(
|
||||
({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize, readOnly = false }, ref) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lineNumbersRef = useRef<HTMLDivElement>(null);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const prevNoteIdRef = useRef(noteId);
|
||||
const prevValueRef = useRef(value);
|
||||
|
||||
// Undo/redo history for the source textarea (native textarea undo is
|
||||
// unreliable once the value is controlled by React).
|
||||
const undoStackRef = useRef<SourceHistorySnapshot[]>([]);
|
||||
const redoStackRef = useRef<SourceHistorySnapshot[]>([]);
|
||||
const lastUserEditRef = useRef<{ inputType: string; at: number; caret: number } | null>(null);
|
||||
const compositionBaselineRef = useRef<SourceHistorySnapshot | null>(null);
|
||||
const skipNextCompositionCommitRef = useRef(false);
|
||||
|
||||
const resetUserEditCoalescing = () => {
|
||||
lastUserEditRef.current = null;
|
||||
};
|
||||
|
||||
const createSnapshot = (
|
||||
snapshotValue = localValue,
|
||||
selectionStart = textareaRef.current?.selectionStart ?? snapshotValue.length,
|
||||
selectionEnd = textareaRef.current?.selectionEnd ?? selectionStart,
|
||||
): SourceHistorySnapshot => ({
|
||||
value: snapshotValue,
|
||||
selectionStart: Math.min(selectionStart, snapshotValue.length),
|
||||
selectionEnd: Math.min(selectionEnd, snapshotValue.length),
|
||||
});
|
||||
|
||||
// Adopt the external value only for genuine external changes:
|
||||
// - noteId switch → always adopt (new note).
|
||||
// - prop value changed AND differs from localValue → external edit.
|
||||
// The parent debounces our own edits and echoes them back with
|
||||
// value === localValue; those echoes must NOT reset the textarea, or the
|
||||
// keystroke would be reverted and the caret would jump to the end.
|
||||
useEffect(() => {
|
||||
if (noteId !== prevNoteIdRef.current) {
|
||||
prevNoteIdRef.current = noteId;
|
||||
prevValueRef.current = value;
|
||||
setLocalValue(value);
|
||||
undoStackRef.current = [];
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
resetUserEditCoalescing();
|
||||
return;
|
||||
}
|
||||
if (value !== prevValueRef.current) {
|
||||
prevValueRef.current = value;
|
||||
if (value !== localValue) {
|
||||
setLocalValue(value);
|
||||
undoStackRef.current = [];
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
resetUserEditCoalescing();
|
||||
}
|
||||
}
|
||||
}, [noteId, value, localValue]);
|
||||
|
||||
const lineCount = (localValue.match(/\n/g)?.length || 0) + 1;
|
||||
const lineNumbers = useMemo(
|
||||
() => Array.from({ length: lineCount }, (_, i) => i + 1).join("\n"),
|
||||
[lineCount],
|
||||
);
|
||||
const gutterWidth = Math.max(48, String(lineCount).length * 9 + 24);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (textareaRef.current && lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (readOnly) return;
|
||||
const nextValue = e.target.value;
|
||||
if (nextValue === localValue) return;
|
||||
const nativeInputType = (e.nativeEvent as InputEvent | undefined)?.inputType;
|
||||
const inputType = typeof nativeInputType === "string" && nativeInputType
|
||||
? nativeInputType
|
||||
: "input";
|
||||
const isCompositionInput = inputType === "insertCompositionText"
|
||||
|| inputType === "insertFromComposition";
|
||||
if (isCompositionInput) {
|
||||
if (inputType === "insertFromComposition" && skipNextCompositionCommitRef.current) {
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
if (compositionBaselineRef.current === null) {
|
||||
const edit = getSourceEditDelta(localValue, nextValue);
|
||||
const baseline = createSnapshot(
|
||||
localValue,
|
||||
edit.start,
|
||||
edit.start + edit.removedLength,
|
||||
);
|
||||
compositionBaselineRef.current = baseline;
|
||||
undoStackRef.current.push(baseline);
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
if (inputType === "insertFromComposition") compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
compositionBaselineRef.current = null;
|
||||
const now = Date.now();
|
||||
const edit = getSourceEditDelta(localValue, nextValue);
|
||||
if (!shouldCoalesceSourceUndoStep(lastUserEditRef.current, inputType, now, edit)) {
|
||||
const previousSelectionStart = inputType === "deleteContentBackward"
|
||||
? edit.start + edit.removedLength
|
||||
: edit.start;
|
||||
undoStackRef.current.push(createSnapshot(
|
||||
localValue,
|
||||
previousSelectionStart,
|
||||
edit.start + edit.removedLength,
|
||||
));
|
||||
}
|
||||
const caret = typeof e.target.selectionStart === "number"
|
||||
? e.target.selectionStart
|
||||
: edit.start + edit.insertedLength;
|
||||
lastUserEditRef.current = { inputType, at: now, caret };
|
||||
redoStackRef.current = [];
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
const applyHistoryAction = (action: "undo" | "redo"): boolean => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return false;
|
||||
const stack = action === "undo" ? undoStackRef.current : redoStackRef.current;
|
||||
const target = stack.pop();
|
||||
if (target === undefined) return false;
|
||||
resetUserEditCoalescing();
|
||||
compositionBaselineRef.current = null;
|
||||
const current = createSnapshot();
|
||||
if (action === "undo") {
|
||||
redoStackRef.current.push(current);
|
||||
} else {
|
||||
undoStackRef.current.push(current);
|
||||
}
|
||||
setLocalValue(target.value);
|
||||
onChange(target.value);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(target.selectionStart, target.selectionEnd);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
// Read-only fallback (preview mode): the DOM readOnly attribute blocks
|
||||
// typing but not our custom Tab/undo handling, which would still mutate
|
||||
// and persist the note.
|
||||
if (readOnly) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const commandModifier = e.metaKey || e.ctrlKey;
|
||||
const historyAction = commandModifier && !e.altKey
|
||||
? key === "z"
|
||||
? (e.shiftKey ? "redo" : "undo")
|
||||
: key === "y" && e.ctrlKey
|
||||
? "redo"
|
||||
: null
|
||||
: null;
|
||||
if (historyAction) {
|
||||
e.preventDefault();
|
||||
applyHistoryAction(historyAction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"].includes(e.key)) {
|
||||
resetUserEditCoalescing();
|
||||
}
|
||||
|
||||
// Handle Tab insertion
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const nextValue = `${localValue.substring(0, start)} ${localValue.substring(end)}`;
|
||||
undoStackRef.current.push(createSnapshot(localValue, start, end));
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
setLocalValue(nextValue);
|
||||
onChange(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = start + 2;
|
||||
textarea.selectionEnd = start + 2;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertAction: (action: MarkdownActionType) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || readOnly) return;
|
||||
|
||||
if (action === "undo" || action === "redo") {
|
||||
applyHistoryAction(action);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const result = wrapMarkdownSyntax(localValue, start, end, action);
|
||||
if (result.text !== localValue) {
|
||||
undoStackRef.current.push(createSnapshot(localValue, start, end));
|
||||
redoStackRef.current = [];
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
}
|
||||
setLocalValue(result.text);
|
||||
onChange(result.text);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(result.selectionStart, result.selectionEnd);
|
||||
});
|
||||
},
|
||||
focus: () => {
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
scrollToLine: (line: number) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return false;
|
||||
const top = Math.max(0, (Math.max(1, line) - 1) * 24 - 12);
|
||||
if (typeof textarea.scrollTo === "function") {
|
||||
textarea.scrollTo({ top, behavior: "smooth" });
|
||||
} else {
|
||||
textarea.scrollTop = top;
|
||||
}
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = top;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-full w-full bg-background font-mono text-sm select-text overflow-hidden ${className}`}
|
||||
>
|
||||
{/* Line numbers gutter */}
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
style={{ width: `${gutterWidth}px` }}
|
||||
className="shrink-0 py-3 select-none text-right pr-3 text-muted-foreground/40 border-r border-border/50 overflow-hidden font-mono text-sm leading-6"
|
||||
onWheel={(e) => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.scrollTop += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<pre className="m-0 whitespace-pre font-inherit leading-6">{lineNumbers}</pre>
|
||||
</div>
|
||||
|
||||
{/* Source Textarea */}
|
||||
<div className="relative flex-1 h-full min-w-0">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={resetUserEditCoalescing}
|
||||
onCompositionStart={() => {
|
||||
skipNextCompositionCommitRef.current = false;
|
||||
if (compositionBaselineRef.current === null) {
|
||||
const baseline = createSnapshot();
|
||||
compositionBaselineRef.current = baseline;
|
||||
undoStackRef.current.push(baseline);
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
resetUserEditCoalescing();
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
const baseline = compositionBaselineRef.current;
|
||||
if (baseline !== null && baseline.value === localValue) {
|
||||
undoStackRef.current.pop();
|
||||
}
|
||||
skipNextCompositionCommitRef.current = baseline !== null && baseline.value !== localValue;
|
||||
compositionBaselineRef.current = null;
|
||||
resetUserEditCoalescing();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
readOnly={readOnly || undefined}
|
||||
aria-readonly={readOnly || undefined}
|
||||
style={{
|
||||
fontFamily: noteFontFamily || undefined,
|
||||
fontSize: noteFontSize ? `${noteFontSize}px` : undefined,
|
||||
}}
|
||||
className="w-full h-full py-3 px-4 bg-transparent text-foreground resize-none outline-none font-mono text-sm leading-6 whitespace-pre overflow-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
NoteSourceEditor.displayName = "NoteSourceEditor";
|
||||
Reference in New Issue
Block a user