From 5c13e6f1f00a2b688626401150479c51b4fa479a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 07:56:33 +0000 Subject: [PATCH 1/9] Add Markdown Annotator tool to Docusaurus site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive file review tool at /tools/markdown-annotator that lets users annotate markdown and code files like a PR review. Features: - Load files via GitHub URL (auto-fetches raw content, preserves source link) or by pasting content directly with a custom filename - Code view: click line numbers to anchor, shift+click for ranges, double-click to annotate a single line; annotations appear inline below annotated lines - Preview view: select text with the mouse to capture line range, then add annotation via the selection bar; rendered markdown with a lightweight built-in renderer (no external deps) - Annotation dialog with βŒ˜β†΅ submit and Esc cancel - Finish review panel: optional summary text, copy-to-clipboard, and "Open in Claude" / "Open in ChatGPT" link buttons that encode the full review prompt (file contents + inline annotations + source URL metadata) - Works for markdown, MDX, and generic code files (TS, JS, Python, Go, etc.) - Full light/dark theme support via CSS custom properties --- web/src/pages/tools/index.tsx | 8 + .../pages/tools/markdown-annotator.module.css | 942 ++++++++++++++++++ web/src/pages/tools/markdown-annotator.tsx | 891 +++++++++++++++++ 3 files changed, 1841 insertions(+) create mode 100644 web/src/pages/tools/markdown-annotator.module.css create mode 100644 web/src/pages/tools/markdown-annotator.tsx diff --git a/web/src/pages/tools/index.tsx b/web/src/pages/tools/index.tsx index 2bdc899a..1700b6e8 100644 --- a/web/src/pages/tools/index.tsx +++ b/web/src/pages/tools/index.tsx @@ -53,6 +53,14 @@ const tools = [ icon: 'πŸ¦†', tags: ['fun', 'debugging', 'rubber-duck'], }, + { + name: 'Markdown Annotator', + slug: 'markdown-annotator', + description: + 'Review markdown and code files with inline annotations. Fetch from a GitHub URL or paste content, add line-level comments, then export a review prompt for Claude or ChatGPT.', + icon: 'πŸ“', + tags: ['markdown', 'review', 'ai'], + }, ]; const TOOLS_SCHEMA = { diff --git a/web/src/pages/tools/markdown-annotator.module.css b/web/src/pages/tools/markdown-annotator.module.css new file mode 100644 index 00000000..74dd4abc --- /dev/null +++ b/web/src/pages/tools/markdown-annotator.module.css @@ -0,0 +1,942 @@ +/* ── Root ────────────────────────────────────────────────────────────────────── */ + +.toolRoot { + max-width: 1280px; + margin: 0 auto; + padding: 0 1.5rem 4rem; + width: 100%; +} + +/* ── Header ──────────────────────────────────────────────────────────────────── */ + +.toolHeader { + padding: 1.75rem 0 1rem; +} + +.breadcrumb { + font-size: 0.8rem; + color: var(--ifm-color-emphasis-600); + margin-bottom: 0.5rem; +} + +.breadcrumb a, +.breadcrumbBtn { + color: var(--ifm-color-primary); + text-decoration: none; + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: inherit; + font-family: inherit; +} + +.breadcrumb a:hover, +.breadcrumbBtn:hover { + text-decoration: underline; +} + +.toolTitle { + font-size: 1.75rem; + font-weight: 700; + margin: 0 0 0.3rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toolSubtitle { + font-size: 0.9rem; + color: var(--ifm-color-emphasis-600); + margin: 0 0 0.75rem; +} + +.toolTitleRow { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.toolTitleRow .toolTitle { + flex: 1; + min-width: 0; +} + +.toolHeaderActions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + flex-wrap: wrap; +} + +.sourceUrlBar { + margin-top: 0.3rem; + font-size: 0.8rem; +} + +.sourceUrlLink { + color: var(--ifm-color-primary); + text-decoration: none; + word-break: break-all; +} + +.sourceUrlLink:hover { text-decoration: underline; } + +/* ── View toggle ─────────────────────────────────────────────────────────────── */ + +.viewToggle { + display: flex; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 7px; + overflow: hidden; +} + +.viewToggleBtn { + background: none; + border: none; + padding: 0.35rem 0.9rem; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + color: var(--ifm-color-emphasis-700); + transition: background 0.12s, color 0.12s; +} + +.viewToggleBtnActive { + background: var(--ifm-color-primary); + color: #fff; +} + +.viewToggleBtn:not(.viewToggleBtnActive):hover { + background: var(--ifm-color-emphasis-100); +} + +/* ── Header buttons ──────────────────────────────────────────────────────────── */ + +.changeFileBtn { + background: none; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 7px; + padding: 0.35rem 0.8rem; + font-size: 0.8rem; + color: var(--ifm-color-emphasis-700); + cursor: pointer; + transition: background 0.12s; +} + +.changeFileBtn:hover { background: var(--ifm-color-emphasis-100); } + +.finishReviewBtn { + background: var(--ifm-color-primary); + color: #fff; + border: none; + border-radius: 7px; + padding: 0.35rem 1rem; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.12s; + white-space: nowrap; +} + +.finishReviewBtn:hover { opacity: 0.85; } + +/* ── Tool body ───────────────────────────────────────────────────────────────── */ + +.toolBody { + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 10px; + overflow: hidden; + background: var(--ifm-background-surface-color); +} + +/* ── File loader ─────────────────────────────────────────────────────────────── */ + +.loader { + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 10px; + background: var(--ifm-background-surface-color); + overflow: hidden; + max-width: 700px; +} + +.loaderTabs { + display: flex; + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.loaderTab { + background: none; + border: none; + padding: 0.7rem 1.2rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + color: var(--ifm-color-emphasis-600); + border-bottom: 2px solid transparent; + transition: color 0.12s, border-color 0.12s; + margin-bottom: -1px; +} + +.loaderTabActive { + color: var(--ifm-color-primary); + border-bottom-color: var(--ifm-color-primary); +} + +.loaderSection { + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.loaderDesc { + font-size: 0.85rem; + color: var(--ifm-color-emphasis-600); + margin: 0; +} + +.loaderRow { + display: flex; + gap: 0.5rem; +} + +.loaderInput { + flex: 1; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 7px; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-family: inherit; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); +} + +.loaderInput:focus { + outline: none; + border-color: var(--ifm-color-primary); +} + +.loaderFilenameInput { + flex: 1; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 7px; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-family: monospace; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); +} + +.loaderFilenameInput:focus { + outline: none; + border-color: var(--ifm-color-primary); +} + +.loaderTextarea { + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 7px; + padding: 0.6rem 0.75rem; + font-size: 0.8rem; + font-family: monospace; + resize: vertical; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + min-height: 180px; +} + +.loaderTextarea:focus { + outline: none; + border-color: var(--ifm-color-primary); +} + +.loaderBtn { + background: var(--ifm-color-primary); + color: #fff; + border: none; + border-radius: 7px; + padding: 0.5rem 1.1rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.12s; + white-space: nowrap; + align-self: flex-start; +} + +.loaderBtn:hover:not(:disabled) { opacity: 0.85; } +.loaderBtn:disabled { opacity: 0.45; cursor: not-allowed; } + +.loaderError { + margin: 0 1.25rem 1.25rem; + padding: 0.6rem 0.85rem; + background: #fef2f2; + color: #dc2626; + border-radius: 6px; + font-size: 0.85rem; +} + +[data-theme='dark'] .loaderError { + background: #450a0a; + color: #f87171; +} + +/* ── Code view ───────────────────────────────────────────────────────────────── */ + +.codeView { + display: flex; + flex-direction: column; +} + +.codeHint { + padding: 0.5rem 1rem; + font-size: 0.75rem; + color: var(--ifm-color-emphasis-500); + background: var(--ifm-color-emphasis-50, rgba(0,0,0,0.02)); + border-bottom: 1px solid var(--ifm-color-emphasis-200); + user-select: none; +} + +[data-theme='dark'] .codeHint { + background: rgba(255,255,255,0.02); +} + +.codeScroll { + overflow-x: auto; + overflow-y: auto; + max-height: calc(100vh - 220px); +} + +.codeTable { + width: 100%; + border-collapse: collapse; + font-family: monospace; + font-size: 0.82rem; + line-height: 1.55; +} + +.codeLine { + transition: background 0.08s; +} + +.codeLine:hover .lineNum { + background: var(--ifm-color-emphasis-100); +} + +.codeLineSelected { + background: rgba(99, 102, 241, 0.08); +} + +.codeLineAnnotated { + background: rgba(245, 158, 11, 0.06); +} + +[data-theme='dark'] .codeLineSelected { + background: rgba(99, 102, 241, 0.15); +} + +[data-theme='dark'] .codeLineAnnotated { + background: rgba(245, 158, 11, 0.1); +} + +.lineNum { + padding: 0 0.75rem; + min-width: 48px; + text-align: right; + color: var(--ifm-color-emphasis-400); + background: var(--ifm-color-emphasis-50, rgba(0,0,0,0.02)); + border-right: 1px solid var(--ifm-color-emphasis-200); + cursor: pointer; + user-select: none; + vertical-align: top; + white-space: nowrap; + font-size: 0.75rem; + position: relative; +} + +[data-theme='dark'] .lineNum { + background: rgba(255,255,255,0.02); +} + +.lineNum:hover { + color: var(--ifm-color-primary); + background: var(--ifm-color-emphasis-100); +} + +.lineNumAnchor { + background: rgba(99, 102, 241, 0.12) !important; + color: var(--ifm-color-primary) !important; +} + +.annotDot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #f59e0b; + vertical-align: middle; + margin-right: 4px; + margin-top: -1px; +} + +.lineContent { + padding: 0; + vertical-align: top; + width: 100%; +} + +.lineCode { + margin: 0; + padding: 0 1rem; + white-space: pre; + font-family: inherit; + font-size: inherit; + background: none; + border: none; +} + +/* ── Annotation rows (code view) ─────────────────────────────────────────────── */ + +.annotRow { + background: var(--ifm-color-emphasis-50, rgba(0,0,0,0.01)); +} + +[data-theme='dark'] .annotRow { + background: rgba(255,255,255,0.02); +} + +.annotCell { + padding: 0.5rem 0.75rem; +} + +.annotBlock { + border-left: 3px solid #f59e0b; + background: rgba(245, 158, 11, 0.06); + border-radius: 0 6px 6px 0; + padding: 0.5rem 0.75rem; +} + +[data-theme='dark'] .annotBlock { + background: rgba(245, 158, 11, 0.1); +} + +.annotMeta { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.3rem; +} + +.annotRange { + font-size: 0.75rem; + color: var(--ifm-color-emphasis-500); + font-family: monospace; +} + +.annotDelete { + background: none; + border: none; + cursor: pointer; + color: var(--ifm-color-emphasis-400); + font-size: 0.75rem; + padding: 0 0.2rem; + line-height: 1; + border-radius: 3px; + transition: color 0.12s, background 0.12s; +} + +.annotDelete:hover { + color: #dc2626; + background: rgba(220, 38, 38, 0.08); +} + +.annotComment { + font-size: 0.85rem; + color: var(--ifm-font-color-base); + white-space: pre-wrap; + word-break: break-word; +} + +/* ── Preview view ────────────────────────────────────────────────────────────── */ + +.previewView { + display: flex; + flex-direction: column; +} + +.selectionBar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 1rem; + background: rgba(99, 102, 241, 0.08); + border-bottom: 1px solid rgba(99, 102, 241, 0.2); + font-size: 0.82rem; + flex-wrap: wrap; +} + +.selectionInfo { + color: var(--ifm-color-emphasis-700); + flex: 1; +} + +.selectionAddBtn { + background: var(--ifm-color-primary); + color: #fff; + border: none; + border-radius: 6px; + padding: 0.3rem 0.75rem; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.12s; +} + +.selectionAddBtn:hover { opacity: 0.85; } + +.selectionCancelBtn { + background: none; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + padding: 0.3rem 0.6rem; + font-size: 0.8rem; + cursor: pointer; + color: var(--ifm-color-emphasis-600); + transition: background 0.12s; +} + +.selectionCancelBtn:hover { background: var(--ifm-color-emphasis-100); } + +.previewContent { + padding: 1.5rem 2rem; + max-width: 860px; + overflow-x: auto; +} + +/* Markdown rendered content */ +.previewContent h1, +.previewContent h2, +.previewContent h3, +.previewContent h4, +.previewContent h5, +.previewContent h6 { + margin-top: 1.4em; + margin-bottom: 0.5em; + font-weight: 700; + line-height: 1.3; +} + +.previewContent h1 { font-size: 1.8rem; } +.previewContent h2 { font-size: 1.4rem; border-bottom: 1px solid var(--ifm-color-emphasis-200); padding-bottom: 0.3em; } +.previewContent h3 { font-size: 1.15rem; } + +.previewContent p { margin: 0.8em 0; line-height: 1.7; } + +.previewContent code { + font-family: monospace; + font-size: 0.85em; + background: var(--ifm-color-emphasis-100); + padding: 0.15em 0.35em; + border-radius: 4px; +} + +.previewContent pre { + background: var(--ifm-color-emphasis-100); + border-radius: 7px; + padding: 1rem; + overflow-x: auto; + margin: 1em 0; +} + +.previewContent pre code { + background: none; + padding: 0; + font-size: 0.82rem; +} + +.previewContent ul, +.previewContent ol { + padding-left: 1.5rem; + margin: 0.8em 0; +} + +.previewContent li { margin: 0.3em 0; } + +.previewContent blockquote { + border-left: 4px solid var(--ifm-color-emphasis-300); + margin: 1em 0; + padding: 0.5em 1em; + color: var(--ifm-color-emphasis-600); + background: var(--ifm-color-emphasis-50, rgba(0,0,0,0.02)); + border-radius: 0 6px 6px 0; +} + +.previewContent hr { + border: none; + border-top: 1px solid var(--ifm-color-emphasis-300); + margin: 1.5em 0; +} + +.previewContent a { color: var(--ifm-color-primary); } +.previewContent strong { font-weight: 700; } +.previewContent em { font-style: italic; } +.previewContent del { text-decoration: line-through; } + +.previewAnnotations { + border-top: 1px solid var(--ifm-color-emphasis-200); + padding: 1rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.previewAnnotationsTitle { + font-size: 0.82rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ifm-color-emphasis-500); + margin-bottom: 0.25rem; +} + +.previewAnnotBlock { + border-left: 3px solid #f59e0b; + background: rgba(245, 158, 11, 0.06); + border-radius: 0 6px 6px 0; + padding: 0.5rem 0.75rem; +} + +[data-theme='dark'] .previewAnnotBlock { + background: rgba(245, 158, 11, 0.1); +} + +.annotQuote { + font-family: monospace; + font-size: 0.78rem; + background: rgba(0,0,0,0.05); + border-radius: 4px; + padding: 0.4rem 0.6rem; + margin: 0.3rem 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--ifm-color-emphasis-700); +} + +[data-theme='dark'] .annotQuote { + background: rgba(255,255,255,0.06); +} + +/* ── Annotation dialog ───────────────────────────────────────────────────────── */ + +.dialogOverlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeOverlay 0.12s ease; +} + +@keyframes fadeOverlay { + from { opacity: 0; } + to { opacity: 1; } +} + +.dialog { + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 12px; + padding: 1.25rem; + width: min(480px, 92vw); + box-shadow: 0 12px 48px rgba(0,0,0,0.18); + animation: slideDialog 0.15s ease; +} + +@keyframes slideDialog { + from { opacity: 0; transform: translateY(-10px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.dialogHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.dialogTitle { + font-size: 0.9rem; + font-weight: 600; +} + +.dialogClose { + background: none; + border: none; + cursor: pointer; + font-size: 0.85rem; + color: var(--ifm-color-emphasis-500); + padding: 0.2rem 0.4rem; + border-radius: 4px; + transition: background 0.12s; +} + +.dialogClose:hover { background: var(--ifm-color-emphasis-100); color: var(--ifm-font-color-base); } + +.dialogSelection { + margin-bottom: 0.75rem; + background: var(--ifm-color-emphasis-100); + border-radius: 6px; + padding: 0.5rem 0.75rem; +} + +.dialogSelectionText { + margin: 0; + font-size: 0.78rem; + font-family: monospace; + color: var(--ifm-color-emphasis-700); + white-space: pre-wrap; + word-break: break-word; +} + +.dialogTextarea { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + padding: 0.6rem 0.75rem; + font-size: 0.875rem; + font-family: inherit; + resize: vertical; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + min-height: 90px; + transition: border-color 0.12s; +} + +.dialogTextarea:focus { + outline: none; + border-color: var(--ifm-color-primary); +} + +.dialogActions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + justify-content: flex-end; +} + +.dialogCancel { + background: none; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 7px; + padding: 0.4rem 0.85rem; + font-size: 0.85rem; + cursor: pointer; + color: var(--ifm-color-emphasis-700); + transition: background 0.12s; +} + +.dialogCancel:hover { background: var(--ifm-color-emphasis-100); } + +.dialogConfirm { + background: var(--ifm-color-primary); + color: #fff; + border: none; + border-radius: 7px; + padding: 0.4rem 1rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.12s; +} + +.dialogConfirm:hover:not(:disabled) { opacity: 0.85; } +.dialogConfirm:disabled { opacity: 0.4; cursor: not-allowed; } + +.dialogHint { + font-size: 0.72rem; + color: var(--ifm-color-emphasis-400); + margin-top: 0.4rem; + text-align: right; +} + +/* ── Review panel ────────────────────────────────────────────────────────────── */ + +.reviewPanel { + max-width: 820px; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.reviewPanelHeader { + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.reviewPanelTitle { + font-size: 1.1rem; + font-weight: 600; +} + +.reviewStats { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.reviewStat { + font-size: 0.82rem; + color: var(--ifm-color-emphasis-600); +} + +.reviewSourceLink { + font-size: 0.82rem; + color: var(--ifm-color-primary); + text-decoration: none; + word-break: break-all; +} + +.reviewSourceLink:hover { text-decoration: underline; } + +.reviewSummarySection { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.reviewLabel { + font-size: 0.82rem; + font-weight: 600; + color: var(--ifm-color-emphasis-700); +} + +.reviewSummary { + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + padding: 0.6rem 0.85rem; + font-size: 0.875rem; + font-family: inherit; + resize: vertical; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + transition: border-color 0.12s; +} + +.reviewSummary:focus { + outline: none; + border-color: var(--ifm-color-primary); +} + +.reviewActions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.reviewCopyBtn { + background: none; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 7px; + padding: 0.45rem 1rem; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + color: var(--ifm-color-emphasis-700); + transition: background 0.12s; +} + +.reviewCopyBtn:hover { background: var(--ifm-color-emphasis-100); } + +.reviewOpenClaude { + display: inline-flex; + align-items: center; + background: #7c3aed; + color: #fff; + border-radius: 7px; + padding: 0.45rem 1rem; + font-size: 0.85rem; + font-weight: 600; + text-decoration: none; + transition: opacity 0.12s; +} + +.reviewOpenClaude:hover { opacity: 0.85; color: #fff; text-decoration: none; } + +.reviewOpenChatgpt { + display: inline-flex; + align-items: center; + background: #10a37f; + color: #fff; + border-radius: 7px; + padding: 0.45rem 1rem; + font-size: 0.85rem; + font-weight: 600; + text-decoration: none; + transition: opacity 0.12s; +} + +.reviewOpenChatgpt:hover { opacity: 0.85; color: #fff; text-decoration: none; } + +.reviewPromptPreview { + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 8px; + overflow: hidden; + background: var(--ifm-background-surface-color); +} + +.reviewPromptHeader { + padding: 0.5rem 0.85rem; + background: var(--ifm-color-emphasis-100); + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.reviewPromptLabel { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ifm-color-emphasis-500); +} + +.reviewPromptText { + margin: 0; + padding: 1rem; + font-size: 0.78rem; + font-family: monospace; + white-space: pre-wrap; + word-break: break-word; + max-height: 400px; + overflow-y: auto; + color: var(--ifm-font-color-base); +} + +.reviewNewBtn { + background: none; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 7px; + padding: 0.45rem 1rem; + font-size: 0.85rem; + cursor: pointer; + color: var(--ifm-color-emphasis-700); + transition: background 0.12s; + align-self: flex-start; +} + +.reviewNewBtn:hover { background: var(--ifm-color-emphasis-100); } + +/* ── Responsive ──────────────────────────────────────────────────────────────── */ + +@media (max-width: 680px) { + .toolTitleRow { flex-direction: column; align-items: flex-start; } + .toolHeaderActions { width: 100%; } + .previewContent { padding: 1rem; } + .reviewActions { flex-direction: column; } + .reviewOpenClaude, + .reviewOpenChatgpt { justify-content: center; } +} diff --git a/web/src/pages/tools/markdown-annotator.tsx b/web/src/pages/tools/markdown-annotator.tsx new file mode 100644 index 00000000..fcac0f4d --- /dev/null +++ b/web/src/pages/tools/markdown-annotator.tsx @@ -0,0 +1,891 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import Layout from '@theme/Layout'; +import Head from '@docusaurus/Head'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import styles from './markdown-annotator.module.css'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface Annotation { + id: string; + lineStart: number; + lineEnd: number; + selectedText: string; + comment: string; + view: 'code' | 'preview'; +} + +type ViewMode = 'code' | 'preview'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function uid() { + return Math.random().toString(36).slice(2, 10); +} + +function parseGithubUrl(url: string): { rawUrl: string; displayUrl: string } | null { + try { + const u = new URL(url); + if (u.hostname !== 'github.com') return null; + // /owner/repo/blob/branch/path/to/file + const parts = u.pathname.split('/').filter(Boolean); + if (parts.length < 5 || parts[2] !== 'blob') return null; + const [owner, repo, , ...rest] = parts; + const branch = rest[0]; + const filePath = rest.slice(1).join('/'); + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`; + return { rawUrl, displayUrl: url }; + } catch { + return null; + } +} + +function detectLanguage(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + const map: Record = { + ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', + py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java', + c: 'c', cpp: 'cpp', h: 'cpp', cs: 'csharp', php: 'php', + sh: 'bash', bash: 'bash', zsh: 'bash', yaml: 'yaml', yml: 'yaml', + json: 'json', toml: 'toml', md: 'markdown', mdx: 'markdown', + html: 'html', css: 'css', scss: 'scss', sql: 'sql', xml: 'xml', + }; + return map[ext] ?? 'text'; +} + +function isMarkdown(filename: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + return ext === 'md' || ext === 'mdx'; +} + +// ── Minimal Markdown renderer ───────────────────────────────────────────────── + +function renderMarkdown(md: string): string { + let html = md + // Escape HTML first + .replace(/&/g, '&') + .replace(//g, '>'); + + // Fenced code blocks + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { + return `
${code.trimEnd()}
\n`; + }); + + // Inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Split into lines for block-level processing + const lines = html.split('\n'); + const output: string[] = []; + let i = 0; + let inList: 'ul' | 'ol' | null = null; + let inBlockquote = false; + let inPre = false; + + while (i < lines.length) { + const line = lines[i]; + + // Track pre blocks + if (line.startsWith('
')) { inPre = true; output.push(line); i++; continue; }
+    if (line.includes('
')) { inPre = false; output.push(line); i++; continue; } + if (inPre) { output.push(line); i++; continue; } + + // Close open list/blockquote if needed + const isUl = /^[-*+] /.test(line); + const isOl = /^\d+\. /.test(line); + const isBq = line.startsWith('>'); + + if (!isUl && !isOl && inList) { + output.push(inList === 'ul' ? '' : ''); + inList = null; + } + if (!isBq && inBlockquote) { + output.push(''); + inBlockquote = false; + } + + if (line === '' || line === '\n') { + output.push(''); + i++; + continue; + } + + // ATX headings + const hMatch = line.match(/^(#{1,6}) (.+)/); + if (hMatch) { + const level = hMatch[1].length; + output.push(`${inlineFormat(hMatch[2])}`); + i++; continue; + } + + // Horizontal rule + if (/^[-*_]{3,}$/.test(line.trim())) { + output.push('
'); + i++; continue; + } + + // Blockquote + if (isBq) { + if (!inBlockquote) { output.push('
'); inBlockquote = true; } + output.push(`

${inlineFormat(line.replace(/^>\s?/, ''))}

`); + i++; continue; + } + + // Unordered list + if (isUl) { + if (!inList) { output.push('
    '); inList = 'ul'; } + output.push(`
  • ${inlineFormat(line.replace(/^[-*+] /, ''))}
  • `); + i++; continue; + } + + // Ordered list + if (isOl) { + if (!inList) { output.push('
      '); inList = 'ol'; } + output.push(`
    1. ${inlineFormat(line.replace(/^\d+\. /, ''))}
    2. `); + i++; continue; + } + + // Paragraph + output.push(`

      ${inlineFormat(line)}

      `); + i++; + } + + if (inList) output.push(inList === 'ul' ? '
' : ''); + if (inBlockquote) output.push('
'); + + return output.join('\n'); +} + +function inlineFormat(text: string): string { + return text + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); +} + +// ── Review prompt builder ───────────────────────────────────────────────────── + +function buildReviewPrompt(opts: { + content: string; + filename: string; + sourceUrl: string | null; + annotations: Annotation[]; + summary: string; +}): string { + const { content, filename, sourceUrl, annotations, summary } = opts; + const lines: string[] = []; + + lines.push('# File Review'); + lines.push(''); + lines.push(`**File:** \`${filename}\``); + if (sourceUrl) lines.push(`**Source:** ${sourceUrl}`); + lines.push(''); + + if (annotations.length > 0) { + lines.push('## Inline Annotations'); + lines.push(''); + for (const a of annotations) { + const range = a.lineStart === a.lineEnd ? `Line ${a.lineStart}` : `Lines ${a.lineStart}–${a.lineEnd}`; + lines.push(`### ${range}`); + if (a.selectedText) { + lines.push(''); + lines.push('```'); + lines.push(a.selectedText); + lines.push('```'); + } + lines.push(''); + lines.push(a.comment); + lines.push(''); + } + } + + if (summary.trim()) { + lines.push('## Summary'); + lines.push(''); + lines.push(summary.trim()); + lines.push(''); + } + + lines.push('## File Contents'); + lines.push(''); + lines.push('```' + detectLanguage(filename)); + lines.push(content); + lines.push('```'); + + return lines.join('\n'); +} + +// ── Annotation comment dialog ───────────────────────────────────────────────── + +interface AnnotationDialogProps { + lineStart: number; + lineEnd: number; + selectedText: string; + view: ViewMode; + onConfirm: (comment: string) => void; + onCancel: () => void; +} + +function AnnotationDialog({ lineStart, lineEnd, selectedText, onConfirm, onCancel }: AnnotationDialogProps) { + const [comment, setComment] = useState(''); + const textareaRef = useRef(null); + + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + const range = lineStart === lineEnd ? `Line ${lineStart}` : `Lines ${lineStart}–${lineEnd}`; + + return ( +
+
e.stopPropagation()}> +
+ Add comment β€” {range} + +
+ {selectedText && ( +
+
{selectedText.length > 200 ? selectedText.slice(0, 200) + '…' : selectedText}
+
+ )} +