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( ({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize, readOnly = false }, ref) => { const textareaRef = useRef(null); const lineNumbersRef = useRef(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([]); const redoStackRef = useRef([]); const lastUserEditRef = useRef<{ inputType: string; at: number; caret: number } | null>(null); const compositionBaselineRef = useRef(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) => { 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) => { 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 (
{/* Line numbers gutter */}
{ if (textareaRef.current) { textareaRef.current.scrollTop += e.deltaY; } }} >
{lineNumbers}
{/* Source Textarea */}