Add live link functionality and enhance Markdown editor features
- 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.
This commit is contained in:
Generated
+111
-3014
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -20,9 +20,18 @@
|
||||
"storybook:build": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.1.2",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-markdown": "^6.3.4",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/language-data": "^6.5.1",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.38.2",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@mantine/core": "^8.2.8",
|
||||
"@mantine/hooks": "^8.1.2",
|
||||
"@mdxeditor/editor": "^3.42.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"mathjax-full": "^3.2.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.6.2"
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { HomePage } from './pages/Home.page';
|
||||
// import MarkdownEditor from './pages/editor.page';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <HomePage />,
|
||||
},
|
||||
// {
|
||||
// path: '/editor',
|
||||
// element: <MarkdownEditor />,
|
||||
// },
|
||||
]);
|
||||
|
||||
export function Router() {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// src/components/MarkdownEditor.tsx
|
||||
import React, { useState } from "react";
|
||||
import { MDXEditor, toolbarPlugin, headingsPlugin, listsPlugin, quotePlugin, markdownShortcutPlugin } from "@mdxeditor/editor";
|
||||
import "@mdxeditor/editor/style.css";
|
||||
|
||||
export default function MarkdownEditor() {
|
||||
// Local state for markdown
|
||||
const [markdown, setMarkdown] = useState<string>("## Hola MDX!\n\nEscribe aquí...");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<MDXEditor
|
||||
markdown={markdown}
|
||||
onChange={setMarkdown}
|
||||
plugins={[
|
||||
toolbarPlugin(),
|
||||
headingsPlugin(),
|
||||
listsPlugin(),
|
||||
quotePlugin(),
|
||||
markdownShortcutPlugin()
|
||||
]}
|
||||
className="border rounded-md shadow-md bg-white"
|
||||
/>
|
||||
<div className="border rounded-md p-4 bg-gray-50">
|
||||
<h3 className="font-bold">Vista previa:</h3>
|
||||
<pre className="whitespace-pre-wrap">{markdown}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Comments in English
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
|
||||
import { languages } from "@codemirror/language-data"; // language detection for fenced blocks
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
|
||||
import { useMantineColorScheme } from "@mantine/core";
|
||||
import { Prec } from "@codemirror/state";
|
||||
|
||||
import { headingHighlight, headingLineClasses, headingLineTheme } from "./extensions/headings";
|
||||
import { codeBlockLineNumbers, codeBlockCopyButtons, fenceKeydownFallback } from "./extensions/fencedCode";
|
||||
import { liveLinkTheme, liveLinkHandlers, liveLinkKeybind } from "./extensions/liveLinks";
|
||||
import { hideMarkdownDelimiters } from "./extensions/emphasis"; // includes emphasis reveal & hiding
|
||||
import { myMarkdownKeymap } from "./keymap";
|
||||
|
||||
export 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(),
|
||||
codeBlockCopyButtons(),
|
||||
colorScheme === "dark" ? oneDark : EditorView.theme({}, { dark: false }),
|
||||
Prec.highest(keymap.of(myMarkdownKeymap)), // keybindings
|
||||
fenceKeydownFallback(),
|
||||
];
|
||||
|
||||
if (mode === "live") {
|
||||
// Visual Live Preview enhancements
|
||||
extensions.push(
|
||||
syntaxHighlighting(headingHighlight as HighlightStyle),
|
||||
headingLineClasses,
|
||||
headingLineTheme,
|
||||
hideMarkdownDelimiters(), // hide emphasis marks, headers, and link delimiters when not editing them
|
||||
liveLinkTheme,
|
||||
liveLinkHandlers(),
|
||||
Prec.highest(keymap.of([liveLinkKeybind]))
|
||||
);
|
||||
}
|
||||
|
||||
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" }} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Comments in English
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorSelection, ChangeSet } from "@codemirror/state";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
|
||||
// ===== Selection helpers =====
|
||||
export 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 };
|
||||
}
|
||||
|
||||
// ===== Inline wrap / unwrap (idempotent) =====
|
||||
export 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[] = [];
|
||||
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 },
|
||||
];
|
||||
}
|
||||
|
||||
export 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 (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 syntax tree helpers =====
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
function getEmphasisDelimiters(state: EditorView["state"], node: { from: number; to: number; name: string }) {
|
||||
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 } as const;
|
||||
}
|
||||
export function emphasisMarksToReveal(state: EditorView["state"], sel: EditorSelection) {
|
||||
const reveal = new Set<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
// ===== Utilities =====
|
||||
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; });
|
||||
}
|
||||
|
||||
// ===== Main toggle API =====
|
||||
export function toggleEmphasis(view: EditorView, targetWidth: 1|2) {
|
||||
const { state } = view;
|
||||
type Change = { from:number; to:number; insert:string };
|
||||
const allChanges: Change[] = [];
|
||||
const newRanges: {anchor:number; head:number}[] = [];
|
||||
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) {
|
||||
allChanges.push(...wrapSelectionPerLine(state, from, to, targetWidth));
|
||||
newRanges.push({ anchor: from, head: to });
|
||||
continue;
|
||||
}
|
||||
if (hasAnySelection) { newRanges.push({ anchor: from, head: to }); continue; }
|
||||
const expanded = expandToWord(state, from, to);
|
||||
if (expanded.from !== expanded.to) {
|
||||
allChanges.push(...wrapOrUnwrapSegment(state, expanded.from, expanded.to, targetWidth));
|
||||
newRanges.push({ anchor: from, head: from });
|
||||
} else {
|
||||
const stars = "*".repeat(targetWidth*2);
|
||||
allChanges.push({ from, to: from, insert: stars });
|
||||
allChanges.push({ from: to, to: to, insert: stars });
|
||||
newRanges.push({ anchor: from, head: from });
|
||||
}
|
||||
}
|
||||
|
||||
const changes = dedupChanges(allChanges).sort((a,b) => a.from - b.from || a.to - b.to);
|
||||
if (!changes.length) return false;
|
||||
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;
|
||||
}
|
||||
|
||||
export const toggleBold = (view: EditorView) => toggleEmphasis(view, 2);
|
||||
export const toggleItalic = (view: EditorView) => toggleEmphasis(view, 1);
|
||||
|
||||
// ===== Hide Markdown delimiters (live mode) =====
|
||||
import { ViewPlugin, Decoration, DecorationSet } from "@codemirror/view";
|
||||
import { RangeSetBuilder } from "@codemirror/state";
|
||||
import { syntaxTree as treeFn } from "@codemirror/language";
|
||||
import type { EditorSelection as ESel } from "@codemirror/state";
|
||||
import { selectionIntersects } from "../utils/selection";
|
||||
|
||||
export 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 = treeFn(view.state);
|
||||
const sel = view.state.selection;
|
||||
const revealSet = emphasisMarksToReveal(view.state, sel);
|
||||
const { from, to } = view.viewport;
|
||||
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) {
|
||||
if (node.name === "EmphasisMark" || node.name === "StrongMark" || node.name === "StrikethroughMark") {
|
||||
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;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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);
|
||||
const shapeOk = ob !== -1 && cb !== -1 && op !== -1 && cp !== -1 && ob < cb && cb < op && op < cp;
|
||||
if (!shapeOk) return;
|
||||
const labelFrom = node.from + ob + 1;
|
||||
const labelTo = node.from + cb;
|
||||
const urlFrom = node.from + op + 1;
|
||||
const urlTo = node.from + cp;
|
||||
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));
|
||||
const selectionHitsLink = selectionIntersects(sel, node.from, node.to);
|
||||
if (selectionHitsLink) return;
|
||||
if (rf < rt && labelFrom < labelTo) {
|
||||
pending.push({ from: rf, to: rt, deco: Decoration.replace({}), priority: 0 });
|
||||
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 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}});
|
||||
|
||||
pending.sort((a, b) => (a.from - b.from) || (a.priority - b.priority) || (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 });
|
||||
}
|
||||
|
||||
// Export fence toggle here so keymap can import from a single place
|
||||
export { toggleFenceBlock } from "./fencedCode";
|
||||
@@ -0,0 +1,176 @@
|
||||
// Comments in English
|
||||
import { EditorView, GutterMarker, ViewPlugin, Decoration, gutter, WidgetType } from "@codemirror/view";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Range } from "@codemirror/state";
|
||||
import { RangeSetBuilder } from "@codemirror/state";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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); } }
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
export function toggleFenceBlock(view: EditorView): boolean {
|
||||
const { state } = view;
|
||||
const blocks = fencedBlocksInSelection(state);
|
||||
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);
|
||||
const closeL = state.doc.lineAt(b.to);
|
||||
const delOpenFrom = openL.from;
|
||||
const delOpenTo = Math.min(openL.to + 1, state.doc.length);
|
||||
changes.push({ from: delOpenFrom, to: delOpenTo, insert: "" });
|
||||
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: "" });
|
||||
}
|
||||
changes.sort((a,b) => (b.from - a.from) || (b.to - a.to));
|
||||
view.dispatch({ changes, userEvent: "input.toggleFence" });
|
||||
return true;
|
||||
}
|
||||
|
||||
const tr = state.changeByRange(range => {
|
||||
let from = range.from, to = range.to;
|
||||
const doc = state.doc;
|
||||
const selText = doc.sliceString(from, to);
|
||||
const open = "```"; const close = "```";
|
||||
if (from !== to) {
|
||||
const insert = open + "\n" + selText + "\n" + close + "\n";
|
||||
return { changes: { from, to, insert }, range: EditorSelection.cursor(from + open.length + 1) };
|
||||
}
|
||||
const line = doc.lineAt(from);
|
||||
const onEmptyLine = line.text.trim().length === 0;
|
||||
if (onEmptyLine) {
|
||||
const insert = open + "\n\n" + close + "\n";
|
||||
const start = line.from;
|
||||
return { changes: { from: start, to: line.to, insert }, range: EditorSelection.cursor(start + open.length + 1) };
|
||||
} else {
|
||||
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) };
|
||||
}
|
||||
});
|
||||
if (tr.changes.empty) return false;
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Code block line numbers (gutter) ----
|
||||
export 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) {
|
||||
if (node.name === "FencedCode") {
|
||||
const startLine = view.state.doc.lineAt(node.from);
|
||||
const endLine = view.state.doc.lineAt(node.to);
|
||||
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" }
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Copy button per fenced block ----
|
||||
export 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();
|
||||
const doc = view.state.doc;
|
||||
const startL = doc.lineAt(this.from);
|
||||
const endL = doc.lineAt(this.to);
|
||||
const startInner = startL.to + 1;
|
||||
const endInner = endL.from - 1;
|
||||
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") {
|
||||
const line = view.state.doc.lineAt(node.from);
|
||||
decos.push(Decoration.widget({ widget: new CopyBtnWidget(node.from, node.to), side: 1 }).range(line.to));
|
||||
}
|
||||
}});
|
||||
return Decoration.set(decos, true);
|
||||
}
|
||||
}, { decorations: (v) => v.decorations });
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
// ---- Fallback DOM for Mod+` ----
|
||||
export function fenceKeydownFallback() {
|
||||
return EditorView.domEventHandlers({
|
||||
keydown(event, view) {
|
||||
const isMod = event.metaKey || event.ctrlKey; if (!isMod) return false;
|
||||
const k = (event as KeyboardEvent).key; const looksLikeBacktick = (k === "`" || k === "Dead");
|
||||
if (looksLikeBacktick) { event.preventDefault(); event.stopPropagation(); return toggleFenceBlock(view as EditorView); }
|
||||
if (event.altKey && !event.shiftKey && (k.toLowerCase?.() === 'c')) { event.preventDefault(); event.stopPropagation(); return toggleFenceBlock(view as EditorView); }
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Comments in English
|
||||
import { HighlightStyle } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
import { Decoration, DecorationSet, EditorView, ViewPlugin } from "@codemirror/view";
|
||||
import type { Range } from "@codemirror/state";
|
||||
import { RangeSetBuilder } from "@codemirror/state";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
|
||||
export 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" },
|
||||
]);
|
||||
|
||||
export const headingLineClasses = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) { this.decorations = this.compute(view); }
|
||||
update(update: any) {
|
||||
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;
|
||||
tree.iterate({
|
||||
from, to,
|
||||
enter: (node) => {
|
||||
if (node.name === "ATXHeading") {
|
||||
const line = view.state.doc.lineAt(node.from);
|
||||
const match = /^#{1,6}\s/.exec(line.text);
|
||||
const level = match ? match[0].trim().length : 1;
|
||||
const cls = level === 1 ? "cm-h1-line" :
|
||||
level === 2 ? "cm-h2-line" :
|
||||
level === 3 ? "cm-h3-line" :
|
||||
level === 4 ? "cm-h4-line" :
|
||||
level === 5 ? "cm-h5-line" :
|
||||
level === 6 ? "cm-h6-line" : null;
|
||||
if (cls) {
|
||||
decos.push(Decoration.line({ attributes: { class: cls } }).range(line.from, line.from));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return Decoration.set(decos, true);
|
||||
}
|
||||
},
|
||||
{ decorations: (v: any) => v.decorations }
|
||||
);
|
||||
|
||||
export 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" },
|
||||
".cm-strong": { fontWeight: "bold" },
|
||||
".cm-emphasis": { fontStyle: "italic" },
|
||||
".cm-link": { textDecoration: "underline" },
|
||||
".cm-strikethrough": { textDecoration: "line-through" },
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Comments in English
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { Decoration } from "@codemirror/view";
|
||||
import { selectionIntersects } from "../utils/selection";
|
||||
|
||||
export const liveLinkTheme = EditorView.theme({
|
||||
".cm-live-link": {
|
||||
color: "#3b82f6",
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
userSelect: "text",
|
||||
},
|
||||
});
|
||||
|
||||
export function openExternal(url: string) {
|
||||
try { const u = new URL(url, window.location.href); window.open(u.href, "_blank", "noopener"); } catch {}
|
||||
}
|
||||
|
||||
export 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;
|
||||
const pos = (view as EditorView).posAtDOM(linkEl, 0);
|
||||
const info = linkAt(view as EditorView, pos);
|
||||
if (!info) return false;
|
||||
if (selectionIntersects((view as EditorView).state.selection, info.from, info.to)) return false;
|
||||
const url = linkEl.getAttribute("data-url");
|
||||
if (!url) return false;
|
||||
if (event.button === 0) { event.preventDefault(); event.stopPropagation(); openExternal(url); return true; }
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export 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;
|
||||
},
|
||||
};
|
||||
|
||||
export 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;
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Comments in English
|
||||
import { redo } from "@codemirror/commands";
|
||||
import { toggleBold, toggleItalic, toggleFenceBlock } from "./extensions/emphasis";
|
||||
|
||||
export const myMarkdownKeymap = [
|
||||
// Bold / Italic
|
||||
{ key: "Mod-b", preventDefault: true, run: toggleBold },
|
||||
{ key: "Mod-i", preventDefault: true, run: toggleItalic },
|
||||
|
||||
// Redo
|
||||
{ key: "Mod-Shift-z", preventDefault: true, run: redo },
|
||||
{ key: "Mod-y", preventDefault: true, run: redo },
|
||||
|
||||
// Fenced code block toggle
|
||||
{ key: "Mod-`", preventDefault: true, run: toggleFenceBlock },
|
||||
{ key: "Mod-Shift-`", preventDefault: true, run: toggleFenceBlock },
|
||||
{ key: "Mod-Alt-c", preventDefault: true, run: toggleFenceBlock },
|
||||
] as const;
|
||||
@@ -0,0 +1,6 @@
|
||||
// Comments in English
|
||||
import type { EditorSelection } from "@codemirror/state";
|
||||
export 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;
|
||||
}
|
||||
@@ -1,34 +1,11 @@
|
||||
// src/pages/HomePage.tsx
|
||||
import React, { useState } from "react";
|
||||
import { Container, Title, Paper } from "@mantine/core";
|
||||
import {
|
||||
MDXEditor,
|
||||
toolbarPlugin,
|
||||
headingsPlugin,
|
||||
listsPlugin,
|
||||
quotePlugin,
|
||||
markdownShortcutPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import "@mdxeditor/editor/style.css";
|
||||
import MarkdownEditor from "@/editor/MarkdownEditor";
|
||||
|
||||
export function HomePage() {
|
||||
const [markdown, setMarkdown] = useState<string>("");
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<MDXEditor
|
||||
markdown={markdown}
|
||||
onChange={setMarkdown}
|
||||
plugins={[
|
||||
toolbarPlugin(),
|
||||
headingsPlugin(),
|
||||
listsPlugin(),
|
||||
quotePlugin(),
|
||||
markdownShortcutPlugin(),
|
||||
]}
|
||||
className="border rounded-md bg-white"
|
||||
/>
|
||||
|
||||
</Container>
|
||||
<>
|
||||
<MarkdownEditor mode="live" />
|
||||
{/* o para source mode: */}
|
||||
{/* <MarkdownEditor mode="source" /> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+89
-1720
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user