"use client"; import React from "react"; import { EditorView, basicSetup } from "codemirror"; import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown"; import { languages } from "@codemirror/language-data"; // soporta detección de lenguajes en bloques ```js, ```python, etc. import { oneDark } from "@codemirror/theme-one-dark"; import { useEffect, useRef } from "react"; import { useMantineColorScheme } from "@mantine/core"; import { HighlightStyle, syntaxHighlighting, syntaxTree } from "@codemirror/language"; import { redo } from "@codemirror/commands"; import { tags } from "@lezer/highlight"; import { Decoration, DecorationSet, ViewPlugin, WidgetType, gutter, GutterMarker, keymap } from "@codemirror/view"; // Remove duplicate EditorView import import type { Range } from "@codemirror/state"; import { RangeSetBuilder } from "@codemirror/state"; import { Prec } from "@codemirror/state"; import { EditorSelection } from "@codemirror/state"; import { ChangeSet } from "@codemirror/state"; // 1) Resaltado de tokens (tamaño/color de texto del encabezado) const headingHighlight = HighlightStyle.define([ { tag: tags.heading1, fontSize: "1.8rem", fontWeight: "700" }, { tag: tags.heading2, fontSize: "1.5rem", fontWeight: "700" }, { tag: tags.heading3, fontSize: "1.3rem", fontWeight: "600" }, { tag: tags.heading4, fontSize: "1.1rem", fontWeight: "600" }, { tag: tags.heading5, fontSize: "1rem", fontWeight: "600" }, { tag: tags.heading6, fontSize: "0.9rem", fontWeight: "600" }, ]); // 2) Decoraciones de LÍNEA para aumentar altura real de la fila cuando hay heading const headingLineClasses = ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = this.compute(view); } update(update: { docChanged: boolean; viewportChanged: boolean; view: EditorView }) { if (update.docChanged || update.viewportChanged) { this.decorations = this.compute(update.view); } } compute(view: EditorView) { const decos: Range[] = []; const tree = syntaxTree(view.state); const { from, to } = view.viewport; // solo lo visible (eficiente) tree.iterate({ from, to, enter: (node) => { // ATXHeading es el nodo para #, ##, ### ... en lang-markdown if (node.name === "ATXHeading") { // child "HeaderMark" usa #; nivel = cantidad de # // contamos '#' iniciales para nivel const line = view.state.doc.lineAt(node.from); const match = /^#{1,6}\s/.exec(line.text); const level = match ? match[0].trim().length : 1; let cls: string | null = null; if (level === 1) cls = "cm-h1-line"; else if (level === 2) cls = "cm-h2-line"; else if (level === 3) cls = "cm-h3-line"; else if (level === 4) cls = "cm-h4-line"; else if (level === 5) cls = "cm-h5-line"; else if (level === 6) cls = "cm-h6-line"; if (cls) { decos.push( Decoration.line({ attributes: { class: cls } }).range(line.from, line.from) ); } } }, }); return Decoration.set(decos, true); } }, { decorations: (v: any) => v.decorations } ); // 3) Tema para las CLASES de línea (aquí crece la fila) const headingLineTheme = EditorView.theme({ ".cm-h1-line": { lineHeight: "2.4rem", paddingTop: "0.35rem", paddingBottom: "0.15rem", }, ".cm-h2-line": { lineHeight: "2.0rem", paddingTop: "0.25rem", paddingBottom: "0.1rem", }, ".cm-h3-line": { lineHeight: "1.8rem", paddingTop: "0.2rem", paddingBottom: "0.1rem", }, ".cm-h4-line": { lineHeight: "1.6rem", paddingTop: "0.15rem", paddingBottom: "0.1rem", }, ".cm-h5-line": { lineHeight: "1.4rem", paddingTop: "0.1rem", paddingBottom: "0.05rem", }, ".cm-h6-line": { lineHeight: "1.2rem", paddingTop: "0.05rem", paddingBottom: "0.05rem", }, // Puedes mantener tus estilos de inline si quieres: ".cm-strong": { fontWeight: "bold" }, ".cm-emphasis": { fontStyle: "italic" }, ".cm-link": { textDecoration: "underline" }, ".cm-strikethrough": { textDecoration: "line-through" } }); /** ================== KEYMAP: BOLD/ITALIC/REDO ================== **/ // Utils comunes function expandToWord(state: EditorView["state"], from: number, to: number) { if (from !== to) return { from, to }; const line = state.doc.lineAt(from); const text = line.text; const re = /[^\s\[\]\(\)\*\_~`]+/g; let m: RegExpExecArray | null; while ((m = re.exec(text))) { const s = line.from + m.index; const e = line.from + m.index + m[0].length; if (s <= from && e >= from) return { from: s, to: e }; } return { from, to }; } /** ===== Line-wise wrapping helpers (no selection movement) ===== **/ // Wrap or unwrap exactly the [segFrom, segTo) slice on a single line (idempotente). // Regla simple y reversible: // - Si HAY exactamente targetWidth asteriscos pegados a izquierda y derecha ⇒ QUITAR esos asteriscos. // - En caso contrario ⇒ INSERTAR targetWidth asteriscos a cada lado. function wrapOrUnwrapSegment( state: EditorView["state"], segFrom: number, segTo: number, targetWidth: 1 | 2 ) { type Change = { from: number; to: number; insert: string }; const stars = "*".repeat(targetWidth); const leftFrom = Math.max(0, segFrom - targetWidth); const leftOk = segFrom >= targetWidth && state.doc.sliceString(leftFrom, segFrom) === stars; const rightTo = Math.min(state.doc.length, segTo + targetWidth); const rightOk = state.doc.sliceString(segTo, rightTo) === stars; if (leftOk && rightOk) { const changes: Change[] = []; // Quitar primero el cierre, luego la apertura (para no desplazar índices del segundo borrado). changes.push({ from: segTo, to: segTo + targetWidth, insert: "" }); changes.push({ from: segFrom - targetWidth, to: segFrom, insert: "" }); return changes; } return [ { from: segFrom, to: segFrom, insert: stars }, { from: segTo, to: segTo, insert: stars }, ]; } // Apply per-line wrap/unwrap across a possibly multi-line selection. // This never moves the selection; we always remap the original [from, to]. function wrapSelectionPerLine( state: EditorView["state"], from: number, to: number, targetWidth: 1 | 2 ) { type Change = { from: number; to: number; insert: string }; const changes: Change[] = []; const first = state.doc.lineAt(from); const last = state.doc.lineAt(to); for (let ln = first.number; ln <= last.number; ln++) { const line = state.doc.line(ln); // If selection ends exactly at the start of this line (common when selecting full lines), // skip this line to avoid a zero-length tail segment. if (ln === last.number && to === line.from) break; const segFrom = Math.max(line.from, from); const segTo = Math.min(line.to, to); if (segFrom < segTo) { changes.push(...wrapOrUnwrapSegment(state, segFrom, segTo, targetWidth)); } } return changes; } /** ========= Emphasis helpers based on the syntax tree (multi-line safe) ========= **/ type EmphasisDelims = { openFrom: number; openTo: number; closeFrom: number; closeTo: number; width: 1 | 2 | 3; }; // Count asterisks '*' forward from pos (up to max) function countStarsForward(state: EditorView["state"], pos: number, max = 3) { let k = 0; const len = state.doc.length; while (k < max && pos + k < len && state.doc.sliceString(pos + k, pos + k + 1) === "*") k++; return k; } // Count asterisks '*' backward from pos-1 (up to max) function countStarsBackward(state: EditorView["state"], pos: number, max = 3) { let k = 0; while (k < max && pos - 1 - k >= 0 && state.doc.sliceString(pos - 1 - k, pos - k) === "*") k++; return k; } // Find the smallest Emphasis/StrongEmphasis node that fully contains [from, to] function findEnclosingEmphasisNode(state: EditorView["state"], from: number, to: number) { const tree = syntaxTree(state); let best: { from: number; to: number; name: string } | null = null; const lo = Math.max(0, Math.min(from, to) - 1); const hi = Math.min(state.doc.length, Math.max(from, to) + 1); tree.iterate({ from: lo, to: hi, enter(n) { if ((n.name === "Emphasis" || n.name === "StrongEmphasis") && n.from <= from && n.to >= to) { if (!best || (n.to - n.from) < (best.to - best.from)) best = { from: n.from, to: n.to, name: n.name }; } } }); return best; } // Compute delimiter ranges and width for an Emphasis/StrongEmphasis node function getEmphasisDelimiters(state: EditorView["state"], node: { from: number; to: number; name: string }): EmphasisDelims | null { // We only support '*' markers in this toggle (consistent with previous behavior) const openRun = countStarsForward(state, node.from, 3); const closeRun = countStarsBackward(state, node.to, 3); const width = Math.min(3, Math.min(openRun, closeRun)) as 1 | 2 | 3 | 0; if (!width) return null; return { openFrom: node.from, openTo: node.from + width, closeFrom: node.to - width, closeTo: node.to, width }; } // === NUEVO: cuando el cursor está sobre la marca de apertura/cierre, // revela (no oculta) ambas marcas del par === function emphasisMarksToReveal(state: EditorView["state"], sel: EditorSelection): Set { const reveal = new Set(); // For each caret, reveal both sides of the enclosing emphasis node (if any) for (const r of sel.ranges) { const head = r.head; const node = findEnclosingEmphasisNode(state, head, head); if (!node) continue; const delims = getEmphasisDelimiters(state, node); if (!delims) continue; reveal.add(`${delims.openFrom}:${delims.openTo}`); reveal.add(`${delims.closeFrom}:${delims.closeTo}`); } return reveal; } // Pequeña utilidad para desduplicar cambios idénticos cuando hay multi-selección function dedupChanges(changes: {from:number; to:number; insert:string}[]) { const seen = new Set(); return changes.filter(c => { const k = `${c.from}:${c.to}:${c.insert}`; if (seen.has(k)) return false; seen.add(k); return true; }); } // Normaliza toggles evitando duplicados y apilamientos // targetWidth: 1(italic) | 2(bold) function toggleEmphasis(view: EditorView, targetWidth: 1|2) { const { state } = view; const stars = "*".repeat(targetWidth); // 1) Construimos todos los cambios en un solo arreglo (evita duplicaciones entre rangos) type Change = { from:number; to:number; insert:string }; const allChanges: Change[] = []; const newRanges: {anchor:number; head:number}[] = []; // Si hay AL MENOS un rango no vacío, tratamos todos los no-vacíos por líneas; los vacíos se conservan tal cual const hasAnySelection = state.selection.ranges.some(r => r.from !== r.to); for (const r of state.selection.ranges) { const from = r.from, to = r.to; if (from !== to) { // Multi-línea / selección: aplicar por líneas (idempotente) sin mover la selección allChanges.push(...wrapSelectionPerLine(state, from, to, targetWidth)); newRanges.push({ anchor: from, head: to }); continue; } if (hasAnySelection) { // Si hay otras selecciones activas, respetamos este caret sin hacer nada (evitamos doble toques) newRanges.push({ anchor: from, head: to }); continue; } // === Case C: Caret only === { const expanded = expandToWord(state, from, to); // a) Caret sobre palabra -> mantener atajo de envolver/desenvolver ese segmento if (expanded.from !== expanded.to) { allChanges.push( ...wrapOrUnwrapSegment(state, expanded.from, expanded.to, targetWidth) ); // Mantener el caret donde estaba newRanges.push({ anchor: from, head: from }); continue; } // b) Hueco (sin palabra): SOLO insertar * o ** alrededor y NO mover el caret { const stars = "*".repeat(targetWidth*2); allChanges.push({ from, to: from, insert: stars }); allChanges.push({ from: to, to: to, insert: stars }); // Caret permanece exactamente en la misma posición (sin selección) newRanges.push({ anchor: from, head: from }); continue; } } } const changes = dedupChanges(allChanges).sort((a,b) => a.from - b.from || a.to - b.to); if (!changes.length) return false; // 2) Aplicamos en una sola transacción y mapeamos las selecciones originales const changeSet = ChangeSet.of(changes, state.doc.length); const mappedSel = EditorSelection.create( newRanges.map(r => EditorSelection.range( changeSet.mapPos(r.anchor, 1), changeSet.mapPos(r.head, -1) )) ); view.dispatch({ changes, selection: mappedSel, userEvent: "input.toggleEmphasis" }); return true; } // Ctrl/Cmd + B → toggle bold in Markdown (**) const toggleBold = (view: EditorView) => toggleEmphasis(view, 2); // ---------- Helpers FencedCode ---------- function fenceNodeAt(state: EditorView["state"], pos: number) { const tree = syntaxTree(state); let found: { from: number; to: number } | null = null; tree.iterate({ from: pos, to: pos, enter(n) { if (n.name === "FencedCode" && n.from <= pos && n.to >= pos) { found = { from: n.from, to: n.to }; } } }); return found; } // Devuelve todos los bloques FencedCode que intersectan la selección actual function fencedBlocksInSelection(state: EditorView["state"]) { const blocks: Array<{from:number; to:number}> = []; const seen = new Set(); for (const r of state.selection.ranges) { const posList = [r.from, r.to, state.selection.main.head]; for (const p of posList) { const n = fenceNodeAt(state, p); if (n) { const key = `${n.from}:${n.to}`; if (!seen.has(key)) { seen.add(key); blocks.push(n); } } } } // Si no encontró nada con cabezas, probar por intersección de cada rango con el árbol (viewport podría limitar) if (!blocks.length) { const tree = syntaxTree(state); for (const r of state.selection.ranges) { tree.iterate({ from: r.from, to: r.to, enter(n) { if (n.name === "FencedCode") { const key = `${n.from}:${n.to}`; if (!seen.has(key)) { seen.add(key); blocks.push({from:n.from,to:n.to}); } } } }); } } return blocks; } // Toggle fenced block: si estás dentro de un bloque, lo DESENVUELVE; si no, inserta. function toggleFenceBlock(view: EditorView): boolean { const { state } = view; const blocks = fencedBlocksInSelection(state); // 1) Si hay uno o más bloques en/atravesando la selección → quitar vallas, mantener contenido if (blocks.length) { type Change = { from:number; to:number; insert:string }; const changes: Change[] = []; for (const b of blocks) { const openL = state.doc.lineAt(b.from); // línea con ``` const closeL = state.doc.lineAt(b.to); // línea con ``` de cierre // Quitar línea de apertura + salto de línea siguiente const delOpenFrom = openL.from; const delOpenTo = Math.min(openL.to + 1, state.doc.length); // incluye salto si existe changes.push({ from: delOpenFrom, to: delOpenTo, insert: "" }); // Quitar línea de cierre (y el salto anterior si existe) // Si el carácter anterior a closeL.from es \n, elimínalo para no dejar líneas vacías dobles const beforeClose = Math.max(0, closeL.from - 1); const hasPrevNL = state.doc.sliceString(beforeClose, closeL.from) === "\n"; const delCloseFrom = hasPrevNL ? beforeClose : closeL.from; const delCloseTo = closeL.to; changes.push({ from: delCloseFrom, to: delCloseTo, insert: "" }); } // Importante: ordenar DESC para que los índices no se invaliden changes.sort((a,b) => (b.from - a.from) || (b.to - a.to)); view.dispatch({ changes, userEvent: "input.toggleFence" }); return true; } // 2) Si no hay bloque → insertar uno nuevo (comportamiento anterior) const tr = state.changeByRange(range => { let from = range.from, to = range.to; const doc = state.doc; const selText = doc.sliceString(from, to); // Plantilla del fence const open = "```"; const close = "```"; // Caso 1: hay selección -> envolverla if (from !== to) { const insert = open + "\n" + selText + "\n" + close + "\n"; return { changes: { from, to, insert }, range: EditorSelection.cursor(from + open.length + 1) // dentro del bloque, al principio }; } // Caso 2: sin selección const line = doc.lineAt(from); const onEmptyLine = line.text.trim().length === 0; if (onEmptyLine) { // Sustituir línea vacía por bloque const insert = open + "\n\n" + close + "\n"; // Colocar cursor en la línea intermedia const start = line.from; return { changes: { from: start, to: line.to, insert }, range: EditorSelection.cursor(start + open.length + 1) // después de "```" y salto }; } else { // Insertar bloque debajo de la línea actual const insert = "\n" + open + "\n\n" + close + "\n"; const pos = line.to; return { changes: { from: pos, to: pos, insert }, range: EditorSelection.cursor(pos + 1 + open.length + 1) // inicia dentro del bloque }; } }); if (tr.changes.empty) return false; view.dispatch(tr); return true; } // Custom keymap (give it highest precedence to override defaults if needed) // Inserta un bloque fenced ```...``` (Alt + `) const myMarkdownKeymap = keymap.of([ // Bold: Ctrl/⌘ + B { key: "Mod-b", preventDefault: true, run: toggleBold }, // Italic: Ctrl/⌘ + I (usa la misma normalización) { key: "Mod-i", preventDefault: true, run: (v) => toggleEmphasis(v, 1) }, // Redo: Ctrl/⌘ + Shift + Z (also common: Ctrl/⌘ + Y) { key: "Mod-Shift-z", preventDefault: true, run: redo }, { key: "Mod-y", preventDefault: true, run: redo }, // Code block toggle: Mod + ` (Ctrl en Win/Linux, Cmd en macOS) { key: "Mod-`", preventDefault: true, run: toggleFenceBlock }, // Fallbacks por si la tecla ` es "dead key" en tu layout: { key: "Mod-Shift-`", preventDefault: true, run: toggleFenceBlock }, { key: "Mod-Alt-c", preventDefault: true, run: toggleFenceBlock }, ]); type MarkdownEditorProps = { mode?: "live" | "source"; // live = Live Preview, source = Source Mode }; export default function MarkdownEditor({ mode = "live" }: MarkdownEditorProps) { const editorRef = useRef(null); const { colorScheme } = useMantineColorScheme(); useEffect(() => { if (!editorRef.current) return; const extensions = [ basicSetup, markdown({ base: markdownLanguage, codeLanguages: languages }), codeBlockLineNumbers(), // 👉 Gutter de numeración SOLO dentro de bloques ``` ``` codeBlockCopyButtons(), // 👉 Botón "Copiar" para cada bloque de código colorScheme === "dark" ? oneDark : EditorView.theme({}, { dark: false }), Prec.highest(myMarkdownKeymap), // keybindings (bold/italic/redo/fence) fenceKeydownFallback(), // captura DOM por si ` es dead key (toggle) ]; if (mode === "live") { // Solo en LIVE: estilos visuales que alteran tamaño/alto de encabezados extensions.push( syntaxHighlighting(headingHighlight), headingLineClasses, headingLineTheme, ); extensions.push( hideMarkdownDelimiters(), liveLinkTheme, liveLinkHandlers(), // 👉 abrir con ratón (mousedown) Prec.highest(keymap.of([liveLinkKeybind])) // 👉 Ctrl/⌘ + Enter con máxima precedencia ); // 👉 Live Preview } const view = new EditorView({ doc: "# Hello Markdown\n\n## Subtítulo\n\n```js\nconsole.log('Hola');\n```\n\n\n---\n\n\n[Google](https://google.com)\n\n\n- [x] Tarea completada", extensions, parent: editorRef.current, }); return () => { view.destroy(); }; }, [colorScheme, mode]); return (
); } /** ============== UTILIDADES PARA BLOQUES DE CÓDIGO ============== **/ // 1) Gutter con numeración relativa por bloque (1..N) —solo para líneas dentro de ``` ``` function codeBlockLineNumbers() { class NumMarker extends GutterMarker { constructor(readonly n: number) { super(); } toDOM() { const el = document.createElement("div"); el.className = "cm-codeblock-lno"; el.textContent = String(this.n); return el; } } function markers(view: EditorView) { const b = new RangeSetBuilder(); const tree = syntaxTree(view.state); const { from, to } = view.viewport; tree.iterate({ from, to, enter(node) { // En Markdown, los bloques ```...``` son "FencedCode" if (node.name === "FencedCode") { const startLine = view.state.doc.lineAt(node.from); const endLine = view.state.doc.lineAt(node.to); // Saltamos las líneas de las vallas ```: primera y última let num = 0; for (let i = startLine.number + 1; i <= endLine.number - 1; i++) { num += 1; const line = view.state.doc.line(i); b.add(line.from, line.from, new NumMarker(num)); } } } }); return b.finish(); } return [ gutter({ class: "cm-codeblock-gutter", markers, lineMarkerChange(update) { return update.docChanged || update.viewportChanged; } }), EditorView.theme({ ".cm-gutters": { userSelect: "none" }, ".cm-codeblock-gutter": { width: "3ch", }, ".cm-codeblock-gutter .cm-codeblock-lno": { textAlign: "right", paddingRight: "0.5ch", opacity: 0.7, fontVariantNumeric: "tabular-nums", } }) ]; } // 2) Botón "Copiar" en cada bloque ``` ``` que copia el contenido interno al portapapeles function codeBlockCopyButtons() { class CopyBtnWidget extends WidgetType { constructor(readonly from: number, readonly to: number) { super(); } eq(other: CopyBtnWidget) { return this.from === other.from && this.to === other.to; } toDOM(view: EditorView) { const btn = document.createElement("button"); btn.className = "cm-codeblock-copybtn"; btn.type = "button"; btn.title = "Copiar código"; btn.textContent = "⧉"; btn.onclick = async (e) => { e.preventDefault(); // extrae el texto entre las vallas, excluyendo primera y última línea const doc = view.state.doc; const startL = doc.lineAt(this.from); const endL = doc.lineAt(this.to); const startInner = startL.to + 1; // después del salto del fence de apertura const endInner = endL.from - 1; // antes del fence de cierre const code = doc.sliceString(startInner, endInner); try { await navigator.clipboard.writeText(code); } catch {} }; return btn; } ignoreEvent() { return false; } } const plugin = ViewPlugin.fromClass(class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = this.build(view); } update(u: any) { if (u.docChanged || u.viewportChanged) this.decorations = this.build(u.view); } build(view: EditorView) { const decos: Range[] = []; const tree = syntaxTree(view.state); const { from, to } = view.viewport; tree.iterate({ from, to, enter(node) { if (node.name === "FencedCode") { // Colocamos el botón en la PRIMERA línea del bloque (sobre el fence) const line = view.state.doc.lineAt(node.from); decos.push( Decoration.widget({ widget: new CopyBtnWidget(node.from, node.to), side: 1 }).range(line.to) // al final de la 1ª línea ); } } }); return Decoration.set(decos, true); } }, { decorations: v => v.decorations }); // Estilos para posicionar el botón const theme = EditorView.theme({ ".cm-line": { position: "relative" }, ".cm-codeblock-copybtn": { position: "absolute", right: "0.25rem", top: "0.15rem", fontSize: "0.8rem", padding: "0.1rem 0.3rem", border: "none", borderRadius: "4px", cursor: "pointer", opacity: 0.7, }, ".cm-codeblock-copybtn:hover": { opacity: 1 } }); return [plugin, theme]; } /** ============== OCULTAR DELIMITADORES MARKDOWN ============== **/ // Fallback DOM: si el navegador no entrega bien "Mod-`", lo forzamos aquí. function fenceKeydownFallback() { return EditorView.domEventHandlers({ keydown(event, view) { // Si ya lo manejó el keymap, CodeMirror no llega aquí con preventDefault=true. // Forzamos sólo cuando sea Mod + ` sin otras teclas (o prueba Shift como alternativa). const isMod = event.metaKey || event.ctrlKey; if (!isMod) return false; // Algunas distribuciones envían 'Dead' o distinta representación: const k = event.key; // suele ser '`' o 'Dead' en teclados ES/LA const looksLikeBacktick = (k === '`' || k === 'Dead'); if (looksLikeBacktick) { event.preventDefault(); event.stopPropagation(); return toggleFenceBlock(view); } // Atajo alternativo manual por si lo anterior falla if (event.altKey && !event.shiftKey && (k.toLowerCase?.() === 'c')) { event.preventDefault(); event.stopPropagation(); return toggleFenceBlock(view); } return false; } }); } function hideMarkdownDelimiters() { return ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = this.build(view); } update(update: any) { if (update.docChanged || update.selectionSet || update.viewportChanged) { this.decorations = this.build(update.view); } } build(view: EditorView) { const builder = new RangeSetBuilder(); const tree = syntaxTree(view.state); const sel = view.state.selection; // NUEVO: calcular qué marcas deben mostrarse (no ocultar) porque el cursor está sobre su par const revealSet = emphasisMarksToReveal(view.state, sel); const { from, to } = view.viewport; // Recolectamos y luego ordenamos para cumplir el requisito de orden type PendingDeco = { from: number; to: number; deco: Decoration; priority: number }; const pending: PendingDeco[] = []; const docLen = view.state.doc.length; tree.iterate({ from, to, enter(node) { // Bold / italic delimiters if ( node.name === "EmphasisMark" || node.name === "StrongMark" || node.name === "StrikethroughMark" // 👉 soporta ~~tachado~~ ) { // No ocultar si selección intersecta o si está marcado para revelarse const key = `${node.from}:${node.to}`; const mustReveal = revealSet.has(key); if (!selectionIntersects(sel, node.from, node.to) && !mustReveal) { pending.push({ from: node.from, to: node.to, deco: Decoration.replace({}), priority: 0, }); } return; } // Heading # -> ocultar almohadillas y espacio siguiente, // pero mostrarlos si el cursor está en la misma línea if (node.name === "HeaderMark") { const line = view.state.doc.lineAt(node.from); const cursorInLine = sel.ranges.some( (r) => r.head >= line.from && r.head <= line.to ); if (!cursorInLine) { let hideTo = node.to; if (line.text[node.to - line.from] === " ") { hideTo = node.to + 1; } const rf = Math.max(0, Math.min(node.from, docLen)); const rt = Math.max(0, Math.min(hideTo, docLen)); if (rf < rt) { pending.push({ from: rf, to: rt, deco: Decoration.replace({ block: false }), priority: 0, }); } } } // Links (Live Preview): imitar comportamiento de énfasis (**/***): // - Si la selección NO intersecta el nodo Link => ocultar '[' ']' y '(url)' y hacer clickable el label. // - Si la selección SÍ intersecta el nodo Link => mostrar TODO (editar normal, sin clickable). if (node.name === "Link") { const raw = view.state.doc.sliceString(node.from, node.to); const ob = raw.indexOf("["); const cb = raw.indexOf("]", ob + 1); const op = raw.indexOf("(", cb + 1); const cp = raw.indexOf(")", op + 1); // Validación estricta: debe ser [label](url) const shapeOk = ob !== -1 && cb !== -1 && op !== -1 && cp !== -1 && ob < cb && cb < op && op < cp; if (!shapeOk) { // Formato no soportado (autolink, malformado, etc.) → no tocamos nada return; } const labelFrom = node.from + ob + 1; const labelTo = node.from + cb; const urlFrom = node.from + op + 1; const urlTo = node.from + cp; // Límites seguros if (!(labelFrom < labelTo && urlFrom <= urlTo && node.from <= labelFrom && urlTo <= node.to)) { return; } const url = view.state.doc.sliceString(urlFrom, urlTo); const rf = Math.max(0, Math.min(urlFrom - 1, docLen)); // '(' const rt = Math.max(0, Math.min(urlTo + 1, docLen)); // ')' + 1 // ¿Selección intersecta TODO el link? -> edición normal const selectionHitsLink = selectionIntersects(sel, node.from, node.to); if (selectionHitsLink) return; // PREVIEW: ocultamos delimitadores y hacemos clickeable el label if (rf < rt && labelFrom < labelTo) { // (1) Ocultar "(url)" completo (incluye paréntesis) pending.push({ from: rf, to: rt, deco: Decoration.replace({}), priority: 0, }); // (2) Ocultar '[' y ']' const lbOpen = labelFrom - 1; // '[' const lbClose = labelTo; // ']' if (lbOpen >= node.from && lbOpen < node.to) { pending.push({ from: lbOpen, to: lbOpen + 1, deco: Decoration.replace({}), priority: 0, }); } if (lbClose >= node.from && lbClose < node.to) { pending.push({ from: lbClose, to: lbClose + 1, deco: Decoration.replace({}), priority: 0, }); } // (3) Marcar el label como clicable pending.push({ from: labelFrom, to: labelTo, deco: Decoration.mark({ tagName: "a", attributes: { class: "cm-live-link", href: url, "data-url": url, role: "link", title: url, }, }), priority: 1, }); } } }, }); // Orden: por 'from', luego prioridad (replace antes que mark), luego 'to' pending.sort((a, b) => { if (a.from !== b.from) return a.from - b.from; if (a.priority !== b.priority) return a.priority - b.priority; return a.to - b.to; }); for (const p of pending) { if (p.from < p.to) builder.add(p.from, p.to, p.deco); } return builder.finish(); } }, { decorations: (v) => v.decorations } ); } function selectionIntersects(sel: EditorSelection, from: number, to: number) { for (const r of sel.ranges) { if (r.from <= to && r.to >= from) return true; } return false; } // Tema para los enlaces clicables en Live Preview const liveLinkTheme = EditorView.theme({ ".cm-live-link": { color: "#3b82f6", textDecoration: "underline", cursor: "pointer", // Evita estilos raros de selección de CM sobre userSelect: "text", }, }); /** ============== HANDLERS Y KEYBIND PARA LINKS EN LIVE PREVIEW ============== **/ function openExternal(url: string) { try { const u = new URL(url, window.location.href); window.open(u.href, "_blank", "noopener"); } catch {} } // Abrir con ratón: interceptamos MOUSEDOWN para evitar que CM mueva el cursor function liveLinkHandlers() { return EditorView.domEventHandlers({ mousedown(event, view) { const target = event.target as HTMLElement | null; const linkEl = target?.closest?.(".cm-live-link") as HTMLElement | null; if (!linkEl) return false; // Posición del click en el documento const pos = view.posAtDOM(linkEl, 0); const info = linkAt(view, pos); if (!info) return false; // Si la selección INTERSECTA el link (modo edición), no abrir if (selectionIntersects(view.state.selection, info.from, info.to)) return false; const url = linkEl.getAttribute("data-url"); if (!url) return false; // Solo botón izquierdo if (event.button === 0) { event.preventDefault(); event.stopPropagation(); openExternal(url); return true; } return false; } }); } // Keybinding: Ctrl/⌘ + Enter abre el enlace bajo el cursor (si lo hay) const liveLinkKeybind = { key: "Mod-Enter", run(view: EditorView) { const pos = view.state.selection.main.head; const info = linkAt(view, pos); if (info && info.url) { openExternal(info.url); return true; } return false; }, }; // Encuentra el nodo Link que contiene 'pos' y devuelve su URL function linkAt(view: EditorView, pos: number): { from: number; to: number; url: string } | null { const state = view.state; const tree = syntaxTree(state); let found: { from: number; to: number } | null = null; tree.iterate({ from: pos, to: pos, enter(n) { if (n.name === "Link" && n.from <= pos && n.to >= pos) { found = { from: n.from, to: n.to }; } }, }); if (!found) return null; // Extrae la URL usando el mismo parser de offsets que ya usamos const raw = state.doc.sliceString(found.from, found.to); const ob = raw.indexOf("["); const cb = raw.indexOf("]", ob + 1); const op = raw.indexOf("(", cb + 1); const cp = raw.indexOf(")", op + 1); if (ob === -1 || cb === -1 || op === -1 || cp === -1) return null; const urlFrom = found.from + op + 1; const urlTo = found.from + cp; const url = state.doc.sliceString(urlFrom, urlTo); return { from: found.from, to: found.to, url }; } // Helper functions for star runs (add these near expandToWord) function starRunLeft(state: EditorView["state"], pos: number) { let k = 0; for (let i = pos - 1; i >= 0 && k < 3; i--) { const ch = state.doc.sliceString(i, i + 1); if (ch === "*") k++; else break; } return k; } function starRunRight(state: EditorView["state"], pos: number) { let k = 0; const docLen = state.doc.length; for (let i = pos; i < docLen && k < 3; i++) { const ch = state.doc.sliceString(i, i + 1); if (ch === "*") k++; else break; } return k; }