/** * CodeMirrorEditor.tsx — Gap 4: CodeMirror 6 via esm.sh CDN * * Zero bundle impact: caricato dinamicamente al primo mount. * Fallback graceful a textarea mentre il CDN carica (o in caso di errore). * Safari/iPhone-safe: no backdrop-filter, no SharedArrayBuffer, distrugge view on unmount. * * Lingue supportate: Python, JS, JSX, TS, TSX, HTML, CSS, JSON, Markdown. * Funzionalità: line numbers, syntax highlight, auto-indentazione, bracket matching, * fold gutter, history (undo/redo), autocompletion, Cmd/Ctrl+S → save. */ import { useEffect, useRef, useState, useCallback, memo } from "react"; import { Z_INDEX } from "@/lib/zindex"; import type * as React from "react"; // FIX11: React namespace types import type { VfsTsError } from "@/lib/vfsTsChecker"; // GAP-3 // ── Language detection from filename extension ──────────────────────────────── function detectLang(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase() ?? ""; if (ext === "py") return "python"; if (ext === "js" || ext === "mjs" || ext === "cjs") return "javascript"; if (ext === "jsx") return "jsx"; if (ext === "ts") return "typescript"; if (ext === "tsx") return "tsx"; if (ext === "html" || ext === "htm") return "html"; if (ext === "css" || ext === "scss") return "css"; if (ext === "json") return "json"; if (ext === "md" || ext === "markdown") return "markdown"; return "plain"; } // ── Singleton CDN loader ────────────────────────────────────────────────────── // Tutti i pacchetti dallo stesso host esm.sh con target=es2020 per Safari 15+. // Il browser condivide le istanze dei moduli via URL cache → identità dei tipi preservata. const ESM = "https://esm.sh"; const Q = "?target=es2020"; // S762: typed CDN interface — eliminates 18 ':any' in CMBundle type CMExt = object; // Extension opaque type — used as array element interface CMEditorView { dom: HTMLElement; state: { doc: { toString(): string }; replaceSelection(text: string): object }; dispatch(tr: object): void; focus(): void; destroy(): void; } interface CMEditorViewCtor { new (cfg: { state?: object; parent?: HTMLElement }): CMEditorView; updateListener: { of(fn: (upd: { docChanged: boolean; state: { doc: { toString(): string } } }) => void): CMExt }; theme(spec: Record): CMExt; } interface CMBundle { EditorState: { create(cfg: { doc?: string; extensions?: CMExt[] }): object }; EditorView: CMEditorViewCtor; keymap: { of(bindings: readonly object[]): CMExt }; lineNumbers: () => CMExt; highlightActiveLine: () => CMExt; highlightActiveLineGutter: () => CMExt; drawSelection: () => CMExt; rectangularSelection: (() => CMExt) | null | undefined; crosshairCursor: (() => CMExt) | null | undefined; history: () => CMExt; defaultKeymap: readonly object[]; historyKeymap: readonly object[]; indentWithTab: object; foldKeymap: readonly object[]; indentOnInput: CMExt; syntaxHighlighting: (style: object, opts?: Record) => CMExt; defaultHighlightStyle: object; bracketMatching: () => CMExt; foldGutter: () => CMExt; codeFolding: (() => CMExt) | null | undefined; closeBrackets: () => CMExt; closeBracketsKeymap: readonly object[]; autocompletion: (cfg?: Record) => CMExt; completionKeymap: readonly object[]; oneDark: CMExt; py: { python(): CMExt } | null; js: { javascript(cfg?: Record): CMExt } | null; html: { html(): CMExt } | null; css: { css(): CMExt } | null; json: { json(): CMExt } | null; md: { markdown(): CMExt } | null; } let _cm: CMBundle | null = null; let _cmP: Promise | null = null; function loadCM(): Promise { if (_cm) return Promise.resolve(_cm); if (_cmP) return _cmP; _cmP = (async (): Promise => { const [ stateMod, viewMod, cmdMod, langMod, acMod, themeMod, pyMod, jsMod, htmlMod, cssMod, jsonMod, mdMod, ] = await Promise.all([ import(`${ESM}/@codemirror/state${Q}`), import(`${ESM}/@codemirror/view${Q}`), import(`${ESM}/@codemirror/commands${Q}`), import(`${ESM}/@codemirror/language${Q}`), import(`${ESM}/@codemirror/autocomplete${Q}`), import(`${ESM}/@codemirror/theme-one-dark${Q}`), import(`${ESM}/@codemirror/lang-python${Q}`).catch(() => null), import(`${ESM}/@codemirror/lang-javascript${Q}`).catch(() => null), import(`${ESM}/@codemirror/lang-html${Q}`).catch(() => null), import(`${ESM}/@codemirror/lang-css${Q}`).catch(() => null), import(`${ESM}/@codemirror/lang-json${Q}`).catch(() => null), import(`${ESM}/@codemirror/lang-markdown${Q}`).catch(() => null), ]); _cm = { EditorState: stateMod.EditorState, EditorView: viewMod.EditorView, keymap: viewMod.keymap, lineNumbers: viewMod.lineNumbers, highlightActiveLine: viewMod.highlightActiveLine, highlightActiveLineGutter: viewMod.highlightActiveLineGutter, drawSelection: viewMod.drawSelection, rectangularSelection: viewMod.rectangularSelection ?? null, crosshairCursor: viewMod.crosshairCursor ?? null, history: cmdMod.history, defaultKeymap: cmdMod.defaultKeymap, historyKeymap: cmdMod.historyKeymap, indentWithTab: cmdMod.indentWithTab, foldKeymap: cmdMod.foldKeymap ?? [], indentOnInput: langMod.indentOnInput, syntaxHighlighting: langMod.syntaxHighlighting, defaultHighlightStyle: langMod.defaultHighlightStyle, bracketMatching: langMod.bracketMatching, foldGutter: langMod.foldGutter, codeFolding: langMod.codeFolding ?? null, closeBrackets: acMod.closeBrackets, closeBracketsKeymap: acMod.closeBracketsKeymap, autocompletion: acMod.autocompletion, completionKeymap: acMod.completionKeymap, oneDark: themeMod.oneDark, py: pyMod, js: jsMod, html: htmlMod, css: cssMod, json: jsonMod, md: mdMod, }; return _cm!; })(); return _cmP; } // ── iOS detection (module-level, sync, zero cost) ───────────────────────────── const _isIOS = typeof navigator !== "undefined" && /iPhone|iPad|iPod/.test(navigator.userAgent); // ── Simboli toolbar mobile ──────────────────────────────────────────────────── // Ordinati per frequenza d'uso nel codice. Tab è separato (inserisce " "). const MOBILE_SYMBOLS = [ { label: "⇥", insert: " " }, // Tab (2 spazi) { label: "{", insert: "{" }, { label: "}", insert: "}" }, { label: "(", insert: "(" }, { label: ")", insert: ")" }, { label: "[", insert: "[" }, { label: "]", insert: "]" }, { label: "=", insert: "=" }, { label: ":", insert: ":" }, { label: "->", insert: "->" }, { label: "//", insert: "//" }, { label: '"', insert: '"' }, { label: "'", insert: "'" }, { label: ";", insert: ";" }, ] as const; // ── Props ───────────────────────────────────────────────────────────────────── interface CodeMirrorEditorProps { value: string; onChange: (v: string) => void; filename?: string; onSave?: () => void; style?: React.CSSProperties; tsErrors?: VfsTsError[]; // GAP-3 /** S800: posizione cursore (line, col 1-based) — aggiornata ad ogni keypress */ onCursorChange?: (line: number, col: number) => void; /** S800: dimensione font in px (default 13) — controllata da MobileEditorStatusBar */ fontSize?: number; /** S800: ref per scrollare l'editor a un offset specifico (usato da MobileEditorFindBar) */ scrollToOffsetRef?: React.MutableRefObject<((offset: number) => void) | null>; } // ── Shared textarea style ───────────────────────────────────────────────────── const TA_STYLE: React.CSSProperties = { position: "absolute", inset: 0, width: "100%", height: "100%", padding: "14px 16px", background: "hsl(222 22% 7%)", border: "none", outline: "none", color: "#e4e4e7", fontSize: "0.83rem", lineHeight: 1.7, fontFamily: "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace", resize: "none", boxSizing: "border-box", zIndex: 1, }; // ── N7: SelectionTooltip — Inline AI su testo selezionato ──────────────────── // Appare quando l'utente seleziona testo nell'editor CodeMirror. // Pulsanti: Spiega / Fixa → pre-compilano ChatInputBar via useChatStore.setInput(). interface _TooltipState { top: number; left: number; text: string; visible: boolean; } function _SelectionTooltip({ hostRef }: { hostRef: React.RefObject }) { const [tip, setTip] = useState<_TooltipState>({ top: 0, left: 0, text: "", visible: false }); const hideTimer = useRef | null>(null); // Ascolta il DOM event "selectionchange" sul contenitore CM useEffect(() => { const host = hostRef.current; if (!host) return; const onSel = () => { const sel = window.getSelection(); const txt = sel?.toString().trim() ?? ""; if (!txt || txt.length < 5) { setTip(p => ({ ...p, visible: false })); return; } if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; } const range = sel?.getRangeAt(0); if (!range) return; const rect = range.getBoundingClientRect(); const hRect = host.getBoundingClientRect(); setTip({ visible: true, text: txt, top: rect.top - hRect.top - 42, left: rect.left - hRect.left + rect.width / 2, }); }; document.addEventListener("selectionchange", onSel, { passive: true }); return () => document.removeEventListener("selectionchange", onSel); }, [hostRef]); const send = useCallback((prefix: string) => { const msg = `${prefix}:\n\`\`\`\n${tip.text}\n\`\`\``; try { // Importa useChatStore in modo lazy per evitare dipendenza circolare al caricamento CDN import("@/store/chatStore").then(({ useChatStore: cs }) => { cs.getState().setInput?.(msg); }).catch(() => {}); } catch { /* non-blocking */ } setTip(p => ({ ...p, visible: false })); }, [tip.text]); if (!tip.visible || !tip.text) return null; return (
e.preventDefault()} // evita blur dell'editor > {[ { label: "🔍 Spiega", prefix: "Spiega questo codice" }, { label: "🔧 Fixa", prefix: "Fixa questo codice" }, ].map(({ label, prefix }) => ( ))}
); } // ── Component ───────────────────────────────────────────────────────────────── const CodeMirrorEditor = memo(function CodeMirrorEditor({ value, onChange, filename = "", onSave, style, tsErrors = [], onCursorChange, fontSize = 13, scrollToOffsetRef, }: CodeMirrorEditorProps) { const hostRef = useRef(null); const viewRef = useRef(null); const taRef = useRef(null); const onChangeRef = useRef(onChange); const onSaveRef = useRef(onSave); const onCursorChangeRef = useRef(onCursorChange); const latestValue = useRef(value); // sempre aggiornato nel render per catturare il valore corrente al mount const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); // ── Inserisce testo al cursore (toolbar mobile) ────────────────────────── const insertAtCursor = (text: string) => { // Caso 1: CodeMirror montato e pronto const view = viewRef.current; if (view && status === "ready") { view.dispatch(view.state.replaceSelection(text)); view.focus(); return; } // Caso 2: textarea di fallback const ta = taRef.current; if (!ta) return; const s = ta.selectionStart ?? 0; const en = ta.selectionEnd ?? 0; const next = value.slice(0, s) + text + value.slice(en); onChange(next); requestAnimationFrame(() => { ta.focus(); ta.selectionStart = ta.selectionEnd = s + text.length; }); }; // Mantieni refs aggiornate senza rimontare l'editor useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { onSaveRef.current = onSave; }, [onSave]); useEffect(() => { onCursorChangeRef.current = onCursorChange ?? undefined; }, [onCursorChange]); // Aggiornamento sincrono nel render: al momento del mount effect questo // conterrà il valore più recente passato dal parent (es. dopo switch file). latestValue.current = value; // ── Mount / remount quando cambia il filename (= lingua) ──────────────── useEffect(() => { if (!hostRef.current) return; let aborted = false; setStatus("loading"); const lang = detectLang(filename); loadCM() .then((CM) => { if (aborted || !hostRef.current) return; // ── Language extension ───────────────────────────────────────── let langExt: object[] = []; try { if (lang === "python" && CM.py?.python) langExt = [CM.py.python()]; if (lang === "javascript" && CM.js?.javascript) langExt = [CM.js.javascript({ jsx: false })]; if (lang === "jsx" && CM.js?.javascript) langExt = [CM.js.javascript({ jsx: true })]; if (lang === "typescript" && CM.js?.javascript) langExt = [CM.js.javascript({ typescript: true })]; if (lang === "tsx" && CM.js?.javascript) langExt = [CM.js.javascript({ jsx: true, typescript: true })]; if (lang === "html" && CM.html?.html) langExt = [CM.html.html()]; if (lang === "css" && CM.css?.css) langExt = [CM.css.css()]; if (lang === "json" && CM.json?.json) langExt = [CM.json.json()]; if (lang === "markdown" && CM.md?.markdown) langExt = [CM.md.markdown()]; } catch { /* ignore lang load failure */ } // ── Estensioni base ──────────────────────────────────────────── const extras: object[] = []; if (CM.rectangularSelection) extras.push(CM.rectangularSelection()); if (CM.crosshairCursor) extras.push(CM.crosshairCursor()); if (CM.codeFolding) extras.push(CM.codeFolding()); const keymapBindings = [ ...CM.closeBracketsKeymap, ...CM.defaultKeymap, ...CM.historyKeymap, ...CM.foldKeymap, ...CM.completionKeymap, CM.indentWithTab, { key: "Mod-s", run() { onSaveRef.current?.(); return true; } }, ]; const extensions = [ CM.lineNumbers(), CM.highlightActiveLineGutter(), CM.foldGutter(), CM.drawSelection(), CM.history(), // @ts-expect-error CM.indentOnInput runtime callable not in static type CM.indentOnInput(), CM.syntaxHighlighting(CM.defaultHighlightStyle, { fallback: true }), CM.bracketMatching(), CM.closeBrackets(), CM.autocompletion(), CM.highlightActiveLine(), CM.oneDark, CM.keymap.of(keymapBindings), ...extras, ...langExt, // Listener: propaga modifiche al parent // @ts-expect-error CM updateListener upd type CM.EditorView.updateListener.of((upd: { docChanged: boolean; state: { doc: { toString(): string }; selection: { main: { head: number } } } }) => { if (upd.docChanged) { onChangeRef.current(upd.state.doc.toString()); } // S800: cursor tracking per status bar if (onCursorChangeRef.current) { try { const offset = upd.state.selection.main.head; const text = upd.state.doc.toString().slice(0, offset); const lines = text.split("\n"); const line = lines.length; const col = (lines[lines.length - 1]?.length ?? 0) + 1; onCursorChangeRef.current(line, col); } catch { /* non-blocking */ } } }), // Tema custom per adattarsi alla UI esistente CM.EditorView.theme({ "&": { height: "100%", fontSize: `${fontSize}px`, fontFamily: "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace", }, ".cm-scroller": { overflow: "auto", lineHeight: "1.7" }, ".cm-content": { padding: "12px 0", minHeight: "100%" }, ".cm-gutters": { background: "hsl(222 22% 7%)", border: "none", borderRight: "1px solid rgba(255,255,255,0.06)", }, ".cm-lineNumbers .cm-gutterElement": { color: "rgba(150,150,175,0.32)", minWidth: "38px", padding: "0 8px 0 4px", }, ".cm-foldGutter .cm-gutterElement": { color: "rgba(150,150,175,0.25)", }, ".cm-activeLine": { background: "rgba(255,255,255,0.025)" }, ".cm-activeLineGutter":{ background: "rgba(255,255,255,0.025)" }, "&.cm-focused": { outline: "none" }, "&.cm-focused .cm-cursor": { borderLeftColor: "#60a5fa", borderLeftWidth: "2px" }, ".cm-selectionBackground": { background: "rgba(96,165,250,0.20) !important" }, "&.cm-focused .cm-selectionBackground": { background: "rgba(96,165,250,0.25) !important" }, }), ]; // Monta l'editor nel host (svuota prima per sicurezza) hostRef.current.innerHTML = ""; const view = new CM.EditorView({ state: CM.EditorState.create({ doc: latestValue.current, extensions, }), parent: hostRef.current, }); viewRef.current = view; // S800: espone scrollToOffset per MobileEditorFindBar if (scrollToOffsetRef) { scrollToOffsetRef.current = (offset: number) => { try { view.dispatch({ selection: { anchor: offset }, scrollIntoView: true }); view.focus(); } catch { /* non-blocking */ } }; } if (!aborted) setStatus("ready"); }) .catch((err) => { console.warn("[CodeMirrorEditor] CDN load failed, fallback to textarea:", err); if (!aborted) setStatus("error"); }); return () => { aborted = true; viewRef.current?.destroy(); viewRef.current = null; if (scrollToOffsetRef) scrollToOffsetRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [filename]); // rimonta SOLO quando cambia il file (= lingua) // S800-FIX: aggiorna font size senza rimontare CM (solo DOM override) useEffect(() => { if (!hostRef.current || status !== "ready") return; const el = hostRef.current.querySelector(".cm-editor") as HTMLElement | null; if (el) el.style.fontSize = `${fontSize}px`; }, [fontSize, status]); // ── Sincronizza valore esterno → CM (es. switch file a stesso nome) ────── useEffect(() => { const view = viewRef.current; if (!view) return; const cur = view.state.doc.toString(); if (cur === value) return; view.dispatch({ changes: { from: 0, to: cur.length, insert: value }, }); }, [value]); // ── Navigazione a riga specifica (agent:goto-line) ───────────────────────── // Pattern CustomEvent coerente con agent:file-changed / agent:merge-ts-errors. // Dispatched da ProblemsPanel dopo setRequestOpenPath (200ms delay per Dexie load). // Safari-safe: addEventListener con cleanup, typeof window guard, fail-safe try/catch. useEffect(() => { if (typeof window === "undefined") return; const handler = (e: Event) => { const { line } = (e as CustomEvent<{ line: number }>).detail ?? {}; if (!line || line < 1) return; const view = viewRef.current; if (!view) return; try { const text = view.state.doc.toString(); const texts = text.split("\n"); let offset = 0; for (let i = 0; i < Math.min(line - 1, texts.length - 1); i++) { offset += (texts[i]?.length ?? 0) + 1; } view.dispatch({ selection: { anchor: offset }, scrollIntoView: true }); view.focus(); } catch { /* fail-safe */ } }; window.addEventListener("agent:goto-line", handler); return () => window.removeEventListener("agent:goto-line", handler); }, []); // ── Tastiera nella textarea di fallback ────────────────────────────────── const handleFallbackKey = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); onSave?.(); } if (e.key === "Tab") { e.preventDefault(); const ta = e.currentTarget; const s = ta.selectionStart; const en = ta.selectionEnd; onChange(value.slice(0, s) + " " + value.slice(en)); requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 2; }); } }; return (
{/* ── Toolbar simboli mobile — solo iPhone/iPad ───────────────────── */} {_isIOS && (
e.stopPropagation()} > {MOBILE_SYMBOLS.map(sym => ( ))}
)} {/* ── Editor area ────────────────────────────────────────────────── */}
{/* N7: Inline AI tooltip su selezione */} <_SelectionTooltip hostRef={hostRef} /> {/* Host CodeMirror — sempre nel DOM con dimensioni reali */}
{/* Textarea di fallback — visibile durante caricamento o in caso di errore */} {status !== "ready" && (