Spaces:
Sleeping
Sleeping
| /** | |
| * 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<string, unknown>): 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<string, unknown>) => CMExt; | |
| defaultHighlightStyle: object; | |
| bracketMatching: () => CMExt; | |
| foldGutter: () => CMExt; | |
| codeFolding: (() => CMExt) | null | undefined; | |
| closeBrackets: () => CMExt; | |
| closeBracketsKeymap: readonly object[]; | |
| autocompletion: (cfg?: Record<string, unknown>) => CMExt; | |
| completionKeymap: readonly object[]; | |
| oneDark: CMExt; | |
| py: { python(): CMExt } | null; | |
| js: { javascript(cfg?: Record<string, unknown>): 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<CMBundle> | null = null; | |
| function loadCM(): Promise<CMBundle> { | |
| if (_cm) return Promise.resolve(_cm); | |
| if (_cmP) return _cmP; | |
| _cmP = (async (): Promise<CMBundle> => { | |
| 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<HTMLDivElement | null> }) { | |
| const [tip, setTip] = useState<_TooltipState>({ top: 0, left: 0, text: "", visible: false }); | |
| const hideTimer = useRef<ReturnType<typeof setTimeout> | 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 ( | |
| <div | |
| style={{ | |
| position: "absolute", | |
| top: tip.top, | |
| left: tip.left, | |
| transform: "translateX(-50%)", | |
| zIndex: Z_INDEX.BANNER, | |
| display: "flex", gap: 4, | |
| background: "rgba(14,16,40,0.97)", | |
| border: "1px solid rgba(99,102,241,0.35)", | |
| borderRadius: 8, | |
| padding: "4px 6px", | |
| boxShadow: "0 8px 24px rgba(0,0,0,0.6)", | |
| pointerEvents: "auto", | |
| userSelect: "none", | |
| }} | |
| onMouseDown={e => e.preventDefault()} // evita blur dell'editor | |
| > | |
| {[ | |
| { label: "🔍 Spiega", prefix: "Spiega questo codice" }, | |
| { label: "🔧 Fixa", prefix: "Fixa questo codice" }, | |
| ].map(({ label, prefix }) => ( | |
| <button | |
| key={label} | |
| onPointerDown={e => { e.preventDefault(); send(prefix); }} | |
| style={{ | |
| all: "unset", | |
| cursor: "pointer", | |
| padding: "3px 9px", | |
| borderRadius: 6, | |
| fontSize: "0.7rem", | |
| fontWeight: 600, | |
| color: "#c7d2fe", | |
| background: "rgba(99,102,241,0.12)", | |
| border: "1px solid rgba(99,102,241,0.22)", | |
| touchAction: "manipulation", | |
| transition: "background 0.1s", | |
| }} | |
| onMouseEnter={e => (e.currentTarget.style.background = "rgba(99,102,241,0.25)")} | |
| onMouseLeave={e => (e.currentTarget.style.background = "rgba(99,102,241,0.12)")} | |
| > | |
| {label} | |
| </button> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| // ── Component ───────────────────────────────────────────────────────────────── | |
| const CodeMirrorEditor = memo(function CodeMirrorEditor({ | |
| value, onChange, filename = "", onSave, style, tsErrors = [], | |
| onCursorChange, fontSize = 13, scrollToOffsetRef, | |
| }: CodeMirrorEditorProps) { | |
| const hostRef = useRef<HTMLDivElement>(null); | |
| const viewRef = useRef<CMEditorView | null>(null); | |
| const taRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => { | |
| 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 ( | |
| <div style={{ | |
| flex: 1, | |
| display: "flex", | |
| flexDirection: "column", | |
| overflow: "hidden", | |
| background: "hsl(222 22% 7%)", | |
| ...style, | |
| }}> | |
| {/* ── Toolbar simboli mobile — solo iPhone/iPad ───────────────────── */} | |
| {_isIOS && ( | |
| <div | |
| style={{ | |
| display: "flex", | |
| flexDirection: "row", | |
| overflowX: "auto", | |
| overflowY: "hidden", | |
| gap: "2px", | |
| padding: "4px 6px", | |
| background: "hsl(222 22% 10%)", | |
| borderBottom: "1px solid rgba(255,255,255,0.07)", | |
| flexShrink: 0, | |
| // B7: WebkitOverflowScrolling rimosso — deprecato iOS 13+, causa doppio layer scroll | |
| scrollbarWidth: "none", | |
| }} | |
| onScroll={e => e.stopPropagation()} | |
| > | |
| {MOBILE_SYMBOLS.map(sym => ( | |
| <button | |
| key={sym.label} | |
| onPointerDown={e => { | |
| // pointerDown evita che il focus lasci l'editor su iOS | |
| e.preventDefault(); | |
| insertAtCursor(sym.insert); | |
| }} | |
| style={{ | |
| flexShrink: 0, | |
| minWidth: "34px", | |
| height: "30px", | |
| padding: "0 6px", | |
| background: "hsl(222 22% 16%)", | |
| border: "1px solid rgba(255,255,255,0.10)", | |
| borderRadius: "5px", | |
| color: "#e4e4e7", | |
| fontSize: "0.78rem", | |
| fontFamily: "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace", | |
| cursor: "pointer", | |
| userSelect: "none", | |
| WebkitUserSelect: "none" as React.CSSProperties["WebkitUserSelect"], | |
| touchAction: "manipulation", | |
| }} | |
| > | |
| {sym.label} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* ── Editor area ────────────────────────────────────────────────── */} | |
| <div style={{ flex: 1, position: "relative", overflow: "hidden" }}> | |
| {/* N7: Inline AI tooltip su selezione */} | |
| <_SelectionTooltip hostRef={hostRef} /> | |
| {/* Host CodeMirror — sempre nel DOM con dimensioni reali */} | |
| <div | |
| ref={hostRef} | |
| style={{ | |
| position: "absolute", inset: 0, | |
| opacity: status === "ready" ? 1 : 0, | |
| pointerEvents: status === "ready" ? "auto" : "none", | |
| transition: "opacity 0.12s", | |
| }} | |
| /> | |
| {/* Textarea di fallback — visibile durante caricamento o in caso di errore */} | |
| {status !== "ready" && ( | |
| <textarea | |
| ref={taRef} | |
| value={value} | |
| onChange={e => onChange(e.target.value)} | |
| onKeyDown={handleFallbackKey} | |
| spellCheck={false} | |
| placeholder={ | |
| status === "loading" | |
| ? "Caricamento editor…" | |
| : "Editor non disponibile (offline?)" | |
| } | |
| style={TA_STYLE} | |
| /> | |
| )} | |
| </div> | |
| {/* GAP-3: TS error panel — compact list below editor */} | |
| {tsErrors.length > 0 && ( | |
| <div style={{ | |
| flexShrink: 0, maxHeight: "22vh", overflowY: "auto", | |
| borderTop: "1px solid rgba(248,113,113,0.15)", | |
| background: "rgba(10,8,22,0.97)", | |
| scrollbarWidth: "none", | |
| }}> | |
| {tsErrors.map((e, i) => ( | |
| <div key={i} style={{ | |
| display: "flex", alignItems: "baseline", gap: 6, | |
| padding: "3px 10px", | |
| borderBottom: "1px solid rgba(255,255,255,0.03)", | |
| fontSize: "0.66rem", | |
| fontFamily: "ui-monospace,SFMono-Regular,monospace", | |
| color: e.sev === "error" ? "#f87171" : "#fbbf24", | |
| }}> | |
| <span style={{ opacity: 0.45, flexShrink: 0 }}> | |
| {e.sev === "error" ? "✕" : "△"} L{e.line} | |
| </span> | |
| <span style={{ color: "#a0a0c0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| {e.message} | |
| </span> | |
| <span style={{ opacity: 0.35, flexShrink: 0 }}>TS{e.code}</span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }); | |
| export default CodeMirrorEditor; | |