8ca7e685cc
- Implemented live link support in the Markdown editor, allowing users to click on links and open them in a new tab. - Added keybinding (Ctrl/⌘ + Enter) to open links under the cursor. - Introduced visual styles for live links to enhance user experience. - Enhanced selection utilities to check for intersection with selections. - Created a custom keymap for Markdown editing, including shortcuts for bold, italic, and fenced code blocks. - Improved code block handling with line numbering and copy buttons for easy access. - Added functionality to hide Markdown delimiters in live preview mode. - Refactored and organized code for better readability and maintainability.
1009 lines
36 KiB
TypeScript
1009 lines
36 KiB
TypeScript
"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<Decoration>[] = [];
|
|
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<string> {
|
|
const reveal = new Set<string>();
|
|
// 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<string>();
|
|
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<string>();
|
|
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<HTMLDivElement | null>(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 (
|
|
<div
|
|
ref={editorRef}
|
|
style={{
|
|
borderRadius: "8px",
|
|
// sin borde, como pediste
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** ============== 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<GutterMarker>();
|
|
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<Decoration>[] = [];
|
|
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<Decoration>();
|
|
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 <a>
|
|
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;
|
|
} |