mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
feat(ui): code file viewer with syntax highlighting and annotations (#634)
* feat(ui): extract reusable PopoutDialog, fix backdrop blur
The table popout lost its backdrop blur when we switched to modal={false}
to keep annotation toolbars interactive. Radix ignores Dialog.Overlay in
non-modal mode, so replace it with a plain div backdrop that works
regardless. Extract the dialog shell (backdrop, close button, portal,
annotation-aware dismiss) into a reusable PopoutDialog component for
upcoming use cases. Add a demo table to the dev plan content.
* Add read-only code file popout
* Add code file annotation support
* Fix code selection popover position
* fix(editor): restore global-attachment-only drafts
The save condition was broadened to persist drafts with only global
attachments, but the restore handler still skipped applying when both
annotation arrays were empty — silently dropping the attachments.
* fix(ui): import SelectedLineRange from @pierre/diffs base package
SelectedLineRange is not re-exported from @pierre/diffs/react —
import it from the base @pierre/diffs entry point instead.
* chore: add TODO for bot callback + code annotation limitation
This commit is contained in:
@@ -27,6 +27,10 @@
|
||||
* a more structural diff (outline → full spec) within whichever mode is on.
|
||||
*/
|
||||
import type { Plugin } from 'vite';
|
||||
import { existsSync, readFileSync, statSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { isCodeFilePath } from '../../packages/shared/code-file';
|
||||
import { preloadFile } from '@pierre/diffs/ssr';
|
||||
|
||||
// ─── Default plans (Real-time Collaboration) ─────────────────────────────
|
||||
// What every dev sees when running `bun run dev:hook` without any flag.
|
||||
@@ -569,7 +573,7 @@ export function devMockApi(): Plugin {
|
||||
return {
|
||||
name: 'plannotator-dev-mock-api',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
if (req.url === '/api/plan') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
@@ -606,6 +610,41 @@ export function devMockApi(): Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api/doc?')) {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const reqPath = url.searchParams.get('path');
|
||||
if (!reqPath) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Missing path parameter' }));
|
||||
return;
|
||||
}
|
||||
const base = url.searchParams.get('base');
|
||||
const repoRoot = resolve(import.meta.dirname, '../..');
|
||||
const resolved = resolve(base || repoRoot, reqPath);
|
||||
if (!existsSync(resolved) || statSync(resolved).isDirectory()) {
|
||||
res.statusCode = 404;
|
||||
res.end(JSON.stringify({ error: `File not found: ${reqPath}` }));
|
||||
return;
|
||||
}
|
||||
const contents = readFileSync(resolved, 'utf-8');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (isCodeFilePath(reqPath)) {
|
||||
const displayName = resolved.split('/').pop() || resolved;
|
||||
let prerenderedHTML: string | undefined;
|
||||
try {
|
||||
const result = await preloadFile({
|
||||
file: { name: displayName, contents },
|
||||
options: { disableFileHeader: true },
|
||||
});
|
||||
prerenderedHTML = result.prerenderedHTML;
|
||||
} catch { /* fall back to client-side rendering */ }
|
||||
res.end(JSON.stringify({ codeFile: true, contents, filepath: resolved, prerenderedHTML }));
|
||||
} else {
|
||||
res.end(JSON.stringify({ markdown: contents, filepath: resolved }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"prepublishOnly": "cd ../.. && bun run build:pi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "^1.1.12",
|
||||
"turndown": "^7.2.4",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.64"
|
||||
},
|
||||
|
||||
@@ -24,11 +24,13 @@ import {
|
||||
import { detectObsidianVaults } from "../generated/integrations-common.js";
|
||||
import {
|
||||
isAbsoluteUserPath,
|
||||
isCodeFilePath,
|
||||
resolveMarkdownFile,
|
||||
resolveUserPath,
|
||||
isWithinProjectRoot,
|
||||
} from "../generated/resolve-file.js";
|
||||
import { htmlToMarkdown } from "../generated/html-to-markdown.js";
|
||||
import { preloadFile } from "@pierre/diffs/ssr";
|
||||
|
||||
type Res = ServerResponse;
|
||||
|
||||
@@ -54,7 +56,7 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], extensi
|
||||
}
|
||||
|
||||
/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */
|
||||
export function handleDocRequest(res: Res, url: URL): void {
|
||||
export async function handleDocRequest(res: Res, url: URL): Promise<void> {
|
||||
const requestedPath = url.searchParams.get("path");
|
||||
if (!requestedPath) {
|
||||
json(res, { error: "Missing path parameter" }, 400);
|
||||
@@ -106,6 +108,40 @@ export function handleDocRequest(res: Res, url: URL): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Code files: resolve directly, return raw contents (no markdown conversion)
|
||||
if (isCodeFilePath(requestedPath)) {
|
||||
const resolvedCode = resolveUserPath(requestedPath, resolvedBase || projectRoot);
|
||||
if (!resolvedBase && !isWithinProjectRoot(resolvedCode, projectRoot)) {
|
||||
json(res, { error: "Access denied: path is outside project root" }, 403);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (existsSync(resolvedCode)) {
|
||||
const stat = statSync(resolvedCode);
|
||||
if (stat.size > 2 * 1024 * 1024) {
|
||||
json(res, { error: "File too large (max 2MB)" }, 413);
|
||||
return;
|
||||
}
|
||||
const contents = readFileSync(resolvedCode, "utf-8");
|
||||
const displayName = resolvedCode.split("/").pop() || resolvedCode;
|
||||
let prerenderedHTML: string | undefined;
|
||||
try {
|
||||
const result = await preloadFile({
|
||||
file: { name: displayName, contents },
|
||||
options: { disableFileHeader: true },
|
||||
});
|
||||
prerenderedHTML = result.prerenderedHTML;
|
||||
} catch {
|
||||
// Fall back to client-side rendering
|
||||
}
|
||||
json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML });
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
json(res, { error: `File not found: ${requestedPath}` }, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = resolveMarkdownFile(requestedPath, projectRoot);
|
||||
|
||||
if (result.kind === "ambiguous") {
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function startAnnotateServer(options: {
|
||||
if (!url.searchParams.has("base") && options.filePath && !/^https?:\/\//i.test(options.filePath)) {
|
||||
url.searchParams.set("base", dirname(resolvePath(options.filePath)));
|
||||
}
|
||||
handleDocRequest(res, url);
|
||||
await handleDocRequest(res, url);
|
||||
} else if (url.pathname === "/api/obsidian/vaults") {
|
||||
handleObsidianVaultsRequest(res);
|
||||
} else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
|
||||
|
||||
@@ -246,7 +246,7 @@ export async function startPlanReviewServer(options: {
|
||||
} else if (externalAnnotations && (await externalAnnotations.handle(req, res, url))) {
|
||||
return;
|
||||
} else if (url.pathname === "/api/doc" && req.method === "GET") {
|
||||
handleDocRequest(res, url);
|
||||
await handleDocRequest(res, url);
|
||||
} else if (url.pathname === "/api/obsidian/vaults") {
|
||||
handleObsidianVaultsRequest(res);
|
||||
} else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
|
||||
|
||||
@@ -6,7 +6,7 @@ cd "$(dirname "$0")"
|
||||
|
||||
mkdir -p generated generated/ai/providers
|
||||
|
||||
for f in feedback-templates prompts review-core storage draft project pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference; do
|
||||
for f in feedback-templates prompts review-core storage draft project pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference; do
|
||||
src="../../packages/shared/$f.ts"
|
||||
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
|
||||
done
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@plannotator/editor": "workspace:*",
|
||||
"@plannotator/review-editor": "workspace:*",
|
||||
"@plannotator/server": "workspace:*",
|
||||
"@plannotator/shared": "workspace:*",
|
||||
"@plannotator/ui": "workspace:*",
|
||||
@@ -89,6 +90,7 @@
|
||||
"version": "0.19.3",
|
||||
"dependencies": {
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.64",
|
||||
"@pierre/diffs": "^1.1.12",
|
||||
"turndown": "^7.2.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -191,6 +193,7 @@
|
||||
"name": "@plannotator/server",
|
||||
"version": "0.19.3",
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "^1.1.12",
|
||||
"@plannotator/ai": "workspace:*",
|
||||
"@plannotator/shared": "workspace:*",
|
||||
},
|
||||
@@ -210,6 +213,8 @@
|
||||
"name": "@plannotator/ui",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "^1.1.12",
|
||||
"@plannotator/review-editor": "workspace:*",
|
||||
"@plannotator/shared": "workspace:*",
|
||||
"@plannotator/web-highlighter": "^0.8.1",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
+131
-23
@@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { type Origin, getAgentName } from '@plannotator/shared/agents';
|
||||
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser';
|
||||
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser';
|
||||
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
|
||||
import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel';
|
||||
import { ExportModal } from '@plannotator/ui/components/ExportModal';
|
||||
import { ImportModal } from '@plannotator/ui/components/ImportModal';
|
||||
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
|
||||
import { Annotation, Block, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '@plannotator/ui/types';
|
||||
import { Annotation, Block, EditorMode, type CodeAnnotation, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '@plannotator/ui/types';
|
||||
import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider';
|
||||
import { Tooltip, TooltipProvider } from '@plannotator/ui/components/Tooltip';
|
||||
import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip';
|
||||
@@ -52,6 +52,7 @@ import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton';
|
||||
import { useSidebar, type SidebarTab } from '@plannotator/ui/hooks/useSidebar';
|
||||
import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff';
|
||||
import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc';
|
||||
import { useCodeFilePopout } from '@plannotator/ui/hooks/useCodeFilePopout';
|
||||
import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft';
|
||||
import { useArchive } from '@plannotator/ui/hooks/useArchive';
|
||||
import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations';
|
||||
@@ -61,10 +62,12 @@ import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstr
|
||||
import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser';
|
||||
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
|
||||
import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser';
|
||||
import { generateId } from '@plannotator/ui/utils/generateId';
|
||||
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
|
||||
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
|
||||
import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBrowser';
|
||||
import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer';
|
||||
import { CodeFilePopout, type CodeFileAnnotationInput } from '@plannotator/ui/components/CodeFilePopout';
|
||||
import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher';
|
||||
// Demo content toggle. Default: the original Real-time Collaboration plan.
|
||||
// Opt-in diff-engine stress test: `VITE_DIFF_DEMO=1 bun run dev:hook` swaps
|
||||
@@ -90,7 +93,9 @@ type NoteAutoSaveResults = {
|
||||
const App: React.FC = () => {
|
||||
const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [codeAnnotations, setCodeAnnotations] = useState<CodeAnnotation[]>([]);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState<string | null>(null);
|
||||
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||
const [frontmatter, setFrontmatter] = useState<Frontmatter | null>(null);
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
@@ -308,6 +313,18 @@ const App: React.FC = () => {
|
||||
viewerRef, sidebar: linkedDocSidebar, sourceFilePath, sourceConverted,
|
||||
});
|
||||
|
||||
// Code file popout (read-only syntax-highlighted overlay)
|
||||
const codeFilePopout = useCodeFilePopout({
|
||||
buildUrl: useCallback((codePath: string) => {
|
||||
const baseDir = linkedDocHook.filepath
|
||||
? linkedDocHook.filepath.replace(/\/[^/]+$/, '')
|
||||
: imageBaseDir;
|
||||
return baseDir
|
||||
? `/api/doc?path=${encodeURIComponent(codePath)}&base=${encodeURIComponent(baseDir)}`
|
||||
: `/api/doc?path=${encodeURIComponent(codePath)}`;
|
||||
}, [linkedDocHook.filepath, imageBaseDir]),
|
||||
});
|
||||
|
||||
// Archive browser
|
||||
const archive = useArchive({
|
||||
markdown, viewerRef, linkedDocHook,
|
||||
@@ -546,11 +563,21 @@ const App: React.FC = () => {
|
||||
// four-term check that was inlined across the annotate-mode header + keyboard paths.
|
||||
const hasAnyAnnotations = useMemo(
|
||||
() => allAnnotations.length > 0
|
||||
|| codeAnnotations.length > 0
|
||||
|| editorAnnotations.length > 0
|
||||
|| linkedDocHook.docAnnotationCount > 0
|
||||
|| globalAttachments.length > 0,
|
||||
[allAnnotations.length, editorAnnotations.length, linkedDocHook.docAnnotationCount, globalAttachments.length],
|
||||
[allAnnotations.length, codeAnnotations.length, editorAnnotations.length, linkedDocHook.docAnnotationCount, globalAttachments.length],
|
||||
);
|
||||
const feedbackAnnotationCount =
|
||||
allAnnotations.length +
|
||||
codeAnnotations.length +
|
||||
editorAnnotations.length +
|
||||
linkedDocHook.docAnnotationCount +
|
||||
globalAttachments.length;
|
||||
// Code-file comments are intentionally not serialized into share URLs in v1.
|
||||
// Hide share entry points once they exist so we do not silently drop feedback.
|
||||
const canShareCurrentSession = sharingEnabled && codeAnnotations.length === 0;
|
||||
|
||||
// URL-based sharing
|
||||
const {
|
||||
@@ -586,6 +613,7 @@ const App: React.FC = () => {
|
||||
// Auto-save annotation drafts
|
||||
const { draftBanner, restoreDraft, dismissDraft } = useAnnotationDraft({
|
||||
annotations: allAnnotations,
|
||||
codeAnnotations,
|
||||
globalAttachments,
|
||||
isApiMode,
|
||||
isSharedSession,
|
||||
@@ -593,9 +621,10 @@ const App: React.FC = () => {
|
||||
});
|
||||
|
||||
const handleRestoreDraft = React.useCallback(() => {
|
||||
const { annotations: restored, globalAttachments: restoredGlobal } = restoreDraft();
|
||||
if (restored.length > 0) {
|
||||
const { annotations: restored, codeAnnotations: restoredCode, globalAttachments: restoredGlobal } = restoreDraft();
|
||||
if (restored.length > 0 || restoredCode.length > 0 || restoredGlobal.length > 0) {
|
||||
setAnnotations(restored);
|
||||
setCodeAnnotations(restoredCode);
|
||||
if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal);
|
||||
// Apply highlights to DOM after a tick
|
||||
setTimeout(() => {
|
||||
@@ -948,7 +977,7 @@ const App: React.FC = () => {
|
||||
const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some(
|
||||
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
|
||||
);
|
||||
if (allAnnotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0) {
|
||||
if (allAnnotations.length > 0 || codeAnnotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0) {
|
||||
body.feedback = annotationsOutput;
|
||||
}
|
||||
|
||||
@@ -994,6 +1023,7 @@ const App: React.FC = () => {
|
||||
body: JSON.stringify({
|
||||
feedback: annotationsOutput,
|
||||
annotations: allAnnotations,
|
||||
codeAnnotations,
|
||||
}),
|
||||
});
|
||||
setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay
|
||||
@@ -1069,7 +1099,7 @@ const App: React.FC = () => {
|
||||
const hasDocAnnotations = Array.from(docAnnotations.values()).some(
|
||||
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
|
||||
);
|
||||
if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
|
||||
if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
|
||||
// Check if agent exists for OpenCode users
|
||||
if (origin === 'opencode') {
|
||||
const warning = getAgentWarning();
|
||||
@@ -1090,7 +1120,7 @@ const App: React.FC = () => {
|
||||
}, [
|
||||
showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning,
|
||||
showPermissionModeSetup, pendingPasteImage,
|
||||
submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, externalAnnotations.length, annotateMode,
|
||||
submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, codeAnnotations.length, externalAnnotations.length, annotateMode,
|
||||
gate, hasAnyAnnotations,
|
||||
origin, getAgentWarning,
|
||||
]);
|
||||
@@ -1098,6 +1128,7 @@ const App: React.FC = () => {
|
||||
const handleAddAnnotation = (ann: Annotation) => {
|
||||
setAnnotations(prev => [...prev, ann]);
|
||||
setSelectedAnnotationId(ann.id);
|
||||
setSelectedCodeAnnotationId(null);
|
||||
if (wideModeType === null) {
|
||||
setIsPanelOpen(true);
|
||||
}
|
||||
@@ -1106,9 +1137,54 @@ const App: React.FC = () => {
|
||||
// Keep selection behavior explicit across mobile/wide-mode transitions.
|
||||
const handleSelectAnnotation = React.useCallback((id: string | null) => {
|
||||
setSelectedAnnotationId(id);
|
||||
if (id) setSelectedCodeAnnotationId(null);
|
||||
if (id && isMobile && wideModeType === null) setIsPanelOpen(true);
|
||||
}, [isMobile, wideModeType]);
|
||||
|
||||
const handleAddCodeAnnotation = React.useCallback((input: CodeFileAnnotationInput) => {
|
||||
const annotation: CodeAnnotation = {
|
||||
id: generateId('code-ann'),
|
||||
type: 'comment',
|
||||
scope: 'line',
|
||||
filePath: input.filePath,
|
||||
lineStart: input.lineStart,
|
||||
lineEnd: input.lineEnd,
|
||||
side: 'new',
|
||||
text: input.text,
|
||||
images: input.images,
|
||||
originalCode: input.originalCode,
|
||||
createdAt: Date.now(),
|
||||
author: configStore.get('displayName') || undefined,
|
||||
};
|
||||
setCodeAnnotations(prev => [...prev, annotation]);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedCodeAnnotationId(annotation.id);
|
||||
if (wideModeType === null) {
|
||||
setIsPanelOpen(true);
|
||||
}
|
||||
}, [wideModeType]);
|
||||
|
||||
// The code popout is full-viewport modal — the annotation panel is behind it.
|
||||
// This handler only fires when the popout is closed (sidebar visible), so
|
||||
// reopening the file via codeFilePopout.open() is the correct behavior.
|
||||
const handleSelectCodeAnnotation = React.useCallback((id: string) => {
|
||||
const annotation = codeAnnotations.find(a => a.id === id);
|
||||
if (!annotation) return;
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedCodeAnnotationId(id);
|
||||
codeFilePopout.open(annotation.filePath);
|
||||
if (isMobile && wideModeType === null) setIsPanelOpen(true);
|
||||
}, [codeAnnotations, codeFilePopout.open, isMobile, wideModeType]);
|
||||
|
||||
const handleDeleteCodeAnnotation = React.useCallback((id: string) => {
|
||||
setCodeAnnotations(prev => prev.filter(a => a.id !== id));
|
||||
if (selectedCodeAnnotationId === id) setSelectedCodeAnnotationId(null);
|
||||
}, [selectedCodeAnnotationId]);
|
||||
|
||||
const handleEditCodeAnnotation = React.useCallback((id: string, updates: Partial<CodeAnnotation>) => {
|
||||
setCodeAnnotations(prev => prev.map(a => a.id === id ? { ...a, ...updates } : a));
|
||||
}, []);
|
||||
|
||||
// Core annotation removal — highlight cleanup + state filter + selection clear
|
||||
const removeAnnotation = (id: string) => {
|
||||
viewerRef.current?.removeHighlight(id);
|
||||
@@ -1158,6 +1234,9 @@ const App: React.FC = () => {
|
||||
setAnnotations(prev => prev.map(ann =>
|
||||
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
|
||||
));
|
||||
setCodeAnnotations(prev => prev.map(ann =>
|
||||
ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann
|
||||
));
|
||||
};
|
||||
|
||||
const handleAddGlobalAttachment = (image: ImageAttachment) => {
|
||||
@@ -1181,8 +1260,9 @@ const App: React.FC = () => {
|
||||
);
|
||||
const hasPlanAnnotations = allAnnotations.length > 0 || globalAttachments.length > 0;
|
||||
const hasEditorAnnotations = editorAnnotations.length > 0;
|
||||
const hasCodeAnnotations = codeAnnotations.length > 0;
|
||||
|
||||
if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations) {
|
||||
if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations && !hasCodeAnnotations) {
|
||||
return 'User reviewed the document and has no feedback.';
|
||||
}
|
||||
|
||||
@@ -1219,10 +1299,17 @@ const App: React.FC = () => {
|
||||
output += exportEditorAnnotations(editorAnnotations);
|
||||
}
|
||||
|
||||
if (hasCodeAnnotations) {
|
||||
output += exportCodeFileAnnotations(codeAnnotations);
|
||||
}
|
||||
|
||||
return output;
|
||||
}, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]);
|
||||
}, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, codeAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]);
|
||||
|
||||
// Bot callback config — read once from URL search params (?cb=&ct=)
|
||||
// TODO: bot callbacks post shareUrl which doesn't include code-file annotations.
|
||||
// If a user adds code comments and hits the callback button, those comments are silently dropped.
|
||||
// Fix: either disable callbacks when codeAnnotations exist, or include annotationsOutput in the payload.
|
||||
const callbackConfig = React.useMemo(() => getCallbackConfig(), []);
|
||||
|
||||
const callCallback = React.useCallback(async (action: CallbackAction) => {
|
||||
@@ -1506,7 +1593,7 @@ const App: React.FC = () => {
|
||||
const hasDocAnnotations = Array.from(docAnnotations.values()).some(
|
||||
(d) => d.annotations.length > 0 || d.globalAttachments.length > 0
|
||||
);
|
||||
if (allAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
|
||||
if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) {
|
||||
setShowFeedbackPrompt(true);
|
||||
} else {
|
||||
handleDeny();
|
||||
@@ -1534,7 +1621,7 @@ const App: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
// Plan mode: existing Claude-Code / OpenCode guards.
|
||||
if (origin === 'claude-code' && allAnnotations.length > 0) {
|
||||
if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) {
|
||||
setShowClaudeCodeWarning(true);
|
||||
return;
|
||||
}
|
||||
@@ -1550,10 +1637,10 @@ const App: React.FC = () => {
|
||||
}}
|
||||
disabled={isSubmitting || (annotateMode && isExiting)}
|
||||
isLoading={isSubmitting}
|
||||
dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && allAnnotations.length > 0}
|
||||
dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0)}
|
||||
title={annotateMode ? 'Approve — no changes requested' : undefined}
|
||||
/>
|
||||
{!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && allAnnotations.length > 0 && (
|
||||
{!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0) && (
|
||||
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
|
||||
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
|
||||
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
|
||||
@@ -1609,7 +1696,7 @@ const App: React.FC = () => {
|
||||
onSaveToObsidian={() => handleQuickSaveToNotes('obsidian')}
|
||||
onSaveToBear={() => handleQuickSaveToNotes('bear')}
|
||||
onSaveToOctarine={() => handleQuickSaveToNotes('octarine')}
|
||||
sharingEnabled={sharingEnabled}
|
||||
sharingEnabled={canShareCurrentSession}
|
||||
isApiMode={isApiMode}
|
||||
agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode}
|
||||
obsidianConfigured={isObsidianConfigured()}
|
||||
@@ -1843,6 +1930,7 @@ const App: React.FC = () => {
|
||||
showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession}
|
||||
maxWidth={annotateReaderMaxWidth}
|
||||
onOpenLinkedDoc={handleOpenLinkedDoc}
|
||||
onOpenCodeFile={codeFilePopout.open}
|
||||
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath)?.isVault ? 'Vault File' : fileBrowser.activeFile ? 'File' : undefined, backLabel } : null}
|
||||
imageBaseDir={imageBaseDir}
|
||||
copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined}
|
||||
@@ -1864,11 +1952,15 @@ const App: React.FC = () => {
|
||||
isOpen={isPanelOpen && wideModeType === null}
|
||||
blocks={blocks}
|
||||
annotations={allAnnotations}
|
||||
selectedId={selectedAnnotationId}
|
||||
onSelect={setSelectedAnnotationId}
|
||||
selectedId={selectedAnnotationId ?? selectedCodeAnnotationId}
|
||||
onSelect={handleSelectAnnotation}
|
||||
onDelete={handleDeleteAnnotation}
|
||||
onEdit={handleEditAnnotation}
|
||||
sharingEnabled={sharingEnabled}
|
||||
codeAnnotations={codeAnnotations}
|
||||
onSelectCodeAnnotation={handleSelectCodeAnnotation}
|
||||
onDeleteCodeAnnotation={handleDeleteCodeAnnotation}
|
||||
onEditCodeAnnotation={handleEditCodeAnnotation}
|
||||
sharingEnabled={canShareCurrentSession}
|
||||
width={panelResize.width}
|
||||
editorAnnotations={editorAnnotations}
|
||||
onDeleteEditorAnnotation={deleteEditorAnnotation}
|
||||
@@ -1876,13 +1968,29 @@ const App: React.FC = () => {
|
||||
onQuickCopy={async () => {
|
||||
await navigator.clipboard.writeText(wrapFeedbackForAgent(annotationsOutput));
|
||||
}}
|
||||
onShare={shareUrl ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined}
|
||||
onShare={canShareCurrentSession && shareUrl ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined}
|
||||
otherFileAnnotations={otherFileAnnotations}
|
||||
onOtherFileAnnotationsClick={handleFlashAnnotatedFiles}
|
||||
/>
|
||||
</div>
|
||||
</ScrollViewportContext.Provider>
|
||||
|
||||
{/* Code File Popout */}
|
||||
{codeFilePopout.popoutProps && (
|
||||
<CodeFilePopout
|
||||
{...codeFilePopout.popoutProps}
|
||||
annotations={codeAnnotations.filter((ann) => ann.filePath === codeFilePopout.popoutProps?.filepath)}
|
||||
selectedAnnotationId={selectedCodeAnnotationId}
|
||||
onAddAnnotation={handleAddCodeAnnotation}
|
||||
onEditAnnotation={handleEditCodeAnnotation}
|
||||
onDeleteAnnotation={handleDeleteCodeAnnotation}
|
||||
onSelectAnnotation={(id) => {
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedCodeAnnotationId(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Export Modal */}
|
||||
<ExportModal
|
||||
isOpen={showExport}
|
||||
@@ -1894,9 +2002,9 @@ const App: React.FC = () => {
|
||||
shortUrlError={shortUrlError}
|
||||
onGenerateShortUrl={generateShortUrl}
|
||||
annotationsOutput={annotationsOutput}
|
||||
annotationCount={allAnnotations.length}
|
||||
annotationCount={allAnnotations.length + codeAnnotations.length}
|
||||
taterSprite={taterMode ? <TaterSpritePullup /> : undefined}
|
||||
sharingEnabled={sharingEnabled}
|
||||
sharingEnabled={canShareCurrentSession}
|
||||
markdown={markdown}
|
||||
isApiMode={isApiMode}
|
||||
initialTab={initialExportTab}
|
||||
@@ -1928,7 +2036,7 @@ const App: React.FC = () => {
|
||||
handleApprove();
|
||||
}}
|
||||
title="Annotations Won't Be Sent"
|
||||
message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length} annotation{allAnnotations.length !== 1 ? 's' : ''} will be lost.</>}
|
||||
message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.</>}
|
||||
subMessage={
|
||||
<>
|
||||
To send feedback, use <strong>Send Feedback</strong> instead.
|
||||
@@ -1956,7 +2064,7 @@ const App: React.FC = () => {
|
||||
else handleAnnotateExit();
|
||||
}}
|
||||
title="Annotations Won't Be Sent"
|
||||
message={<>You have {allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length} annotation{(allAnnotations.length + editorAnnotations.length + linkedDocHook.docAnnotationCount + globalAttachments.length) !== 1 ? 's' : ''} that will be lost if you {exitWarningAction === 'approve' ? 'approve' : 'close'}.</>}
|
||||
message={<>You have {feedbackAnnotationCount} annotation{feedbackAnnotationCount !== 1 ? 's' : ''} that will be lost if you {exitWarningAction === 'approve' ? 'approve' : 'close'}.</>}
|
||||
subMessage="To send your annotations, use Send Annotations instead."
|
||||
confirmText={exitWarningAction === 'approve' ? 'Approve Anyway' : 'Close Anyway'}
|
||||
cancelText="Cancel"
|
||||
|
||||
@@ -2,7 +2,7 @@ export const DEMO_PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration
|
||||
|
||||
## Context
|
||||
|
||||
This proposal introduces real-time collaborative editing to the Plannotator editor, letting reviewers annotate the same plan simultaneously with sub-second visibility of each other's cursors and edits. We are targeting **production-grade concurrency** for up to 50 active collaborators per document, with end-to-end edit-to-visible latency under 150ms at the 95th percentile. The implementation uses operational transforms running on a dedicated Node.js gateway that speaks \`WebSocket\` to clients and \`gRPC\` to the storage tier. See [the technical design doc](https://docs.example.com/realtime-v2) for the full rationale and rollout plan.
|
||||
This proposal introduces real-time collaborative editing to the Plannotator editor, letting reviewers annotate the same plan simultaneously with sub-second visibility of each other's cursors and edits. We are targeting **production-grade concurrency** for up to 50 active collaborators per document, with end-to-end edit-to-visible latency under 150ms at the 95th percentile. The implementation uses operational transforms running on a dedicated Node.js gateway that speaks \`WebSocket\` to clients and \`gRPC\` to the storage tier. See [the technical design doc](https://docs.example.com/realtime-v2) for the full rationale and rollout plan. The editor entry point is [App.tsx](packages/editor/App.tsx) and the server config lives in [server/index.ts](packages/server/index.ts).
|
||||
|
||||
Runtime parameters for phase one:
|
||||
|
||||
@@ -15,6 +15,24 @@ export const COLLAB_CONFIG = {
|
||||
} as const;
|
||||
\`\`\`
|
||||
|
||||
### Configuration reference
|
||||
|
||||
| Parameter | Value | Description |
|
||||
| --- | --- | --- |
|
||||
| \`maxCollaborators\` | 50 | Hard ceiling per document before the gateway rejects new joins |
|
||||
| \`heartbeatIntervalMs\` | 5 000 ms | Ping cadence; three missed heartbeats trigger a reconnect |
|
||||
| \`operationBatchSize\` | 32 | Max ops coalesced into a single WebSocket frame |
|
||||
| \`gateway\` | \`wss://collab.plannotator.ai\` | Regional edge endpoint; clients are routed by latency |
|
||||
|
||||
### Key files
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| [App.tsx](packages/editor/App.tsx) | Editor entry point |
|
||||
| [resolve-file.ts](packages/shared/resolve-file.ts) | Path resolution |
|
||||
| [nonexistent.ts](packages/fake/nope.ts) | Should fail silently |
|
||||
| [package.json](package.json) | Root config |
|
||||
|
||||
## Overview
|
||||
Add real-time collaboration features to the editor using _**[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)**_ and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"*.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "^1.1.12",
|
||||
"@plannotator/shared": "workspace:*",
|
||||
"@plannotator/ai": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -11,11 +11,13 @@ import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/refere
|
||||
import { detectObsidianVaults } from "./integrations";
|
||||
import {
|
||||
isAbsoluteUserPath,
|
||||
isCodeFilePath,
|
||||
resolveMarkdownFile,
|
||||
resolveUserPath,
|
||||
isWithinProjectRoot,
|
||||
} from "@plannotator/shared/resolve-file";
|
||||
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
|
||||
import { preloadFile } from "@pierre/diffs/ssr";
|
||||
|
||||
// --- Route handlers ---
|
||||
|
||||
@@ -72,6 +74,36 @@ export async function handleDoc(req: Request): Promise<Response> {
|
||||
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
|
||||
}
|
||||
|
||||
// Code files: resolve directly, return raw contents (no markdown conversion)
|
||||
if (isCodeFilePath(requestedPath)) {
|
||||
const resolvedCode = resolveUserPath(requestedPath, resolvedBase || projectRoot);
|
||||
if (!resolvedBase && !isWithinProjectRoot(resolvedCode, projectRoot)) {
|
||||
return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const file = Bun.file(resolvedCode);
|
||||
if (await file.exists()) {
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
return Response.json({ error: "File too large (max 2MB)" }, { status: 413 });
|
||||
}
|
||||
const contents = await file.text();
|
||||
const displayName = resolvedCode.split("/").pop() || resolvedCode;
|
||||
let prerenderedHTML: string | undefined;
|
||||
try {
|
||||
const result = await preloadFile({
|
||||
file: { name: displayName, contents },
|
||||
options: { disableFileHeader: true },
|
||||
});
|
||||
prerenderedHTML = result.prerenderedHTML;
|
||||
} catch {
|
||||
// Fall back to client-side rendering
|
||||
}
|
||||
return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const result = resolveMarkdownFile(requestedPath, projectRoot);
|
||||
|
||||
if (result.kind === "ambiguous") {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const CODE_FILE_REGEX = /(?:\.(tsx?|jsx?|py|rb|go|rs|java|c|cpp|h|hpp|cs|swift|kt|scala|sh|bash|zsh|sql|graphql|json|ya?ml|toml|ini|css|scss|less|xml|tf|lua|r|dart|ex|exs|vue|svelte|astro|zig|proto)|(?:^|\/)(Dockerfile|Makefile|Rakefile|Gemfile|Procfile|Vagrantfile|Brewfile|Justfile))$/i;
|
||||
|
||||
export function isCodeFilePath(input: string): boolean {
|
||||
return CODE_FILE_REGEX.test(input.replace(/#.*$/, ''))
|
||||
&& !input.startsWith('http://') && !input.startsWith('https://');
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"./repo": "./repo.ts",
|
||||
"./reference-common": "./reference-common.ts",
|
||||
"./favicon": "./favicon.ts",
|
||||
"./code-file": "./code-file.ts",
|
||||
"./resolve-file": "./resolve-file.ts",
|
||||
"./external-annotation": "./external-annotation.ts",
|
||||
"./agent-jobs": "./agent-jobs.ts",
|
||||
|
||||
@@ -14,6 +14,9 @@ import { isAbsolute, join, resolve, win32 } from "path";
|
||||
import { existsSync, readdirSync, type Dirent } from "fs";
|
||||
|
||||
const MARKDOWN_PATH_REGEX = /\.mdx?$/i;
|
||||
|
||||
export { CODE_FILE_REGEX, isCodeFilePath } from "./code-file";
|
||||
|
||||
const WINDOWS_DRIVE_PATH_PATTERNS = [
|
||||
/^\/cygdrive\/([a-zA-Z])\/(.+)$/,
|
||||
/^\/([a-zA-Z])\/(.+)$/,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Annotation, AnnotationType, Block, type EditorAnnotation } from '../types';
|
||||
import { Annotation, AnnotationType, Block, type CodeAnnotation, type EditorAnnotation } from '../types';
|
||||
import { isCurrentUser } from '../utils/identity';
|
||||
import { ImageThumbnail } from './ImageThumbnail';
|
||||
import { EditorAnnotationCard } from './EditorAnnotationCard';
|
||||
@@ -14,6 +14,10 @@ interface PanelProps {
|
||||
onDelete: (id: string) => void;
|
||||
onEdit?: (id: string, updates: Partial<Annotation>) => void;
|
||||
selectedId: string | null;
|
||||
codeAnnotations?: CodeAnnotation[];
|
||||
onSelectCodeAnnotation?: (id: string) => void;
|
||||
onDeleteCodeAnnotation?: (id: string) => void;
|
||||
onEditCodeAnnotation?: (id: string, updates: Partial<CodeAnnotation>) => void;
|
||||
sharingEnabled?: boolean;
|
||||
width?: number;
|
||||
editorAnnotations?: EditorAnnotation[];
|
||||
@@ -33,6 +37,10 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
||||
onDelete,
|
||||
onEdit,
|
||||
selectedId,
|
||||
codeAnnotations = [],
|
||||
onSelectCodeAnnotation,
|
||||
onDeleteCodeAnnotation,
|
||||
onEditCodeAnnotation,
|
||||
sharingEnabled = true,
|
||||
width,
|
||||
editorAnnotations,
|
||||
@@ -47,7 +55,12 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
||||
const [copiedText, setCopiedText] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
|
||||
const totalCount = annotations.length + (editorAnnotations?.length ?? 0);
|
||||
const sortedCodeAnnotations = [...codeAnnotations].sort((a, b) => a.createdAt - b.createdAt);
|
||||
const timelineEntries = [
|
||||
...sortedAnnotations.map(annotation => ({ kind: 'plan' as const, ts: annotation.createdA, annotation })),
|
||||
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation })),
|
||||
].sort((a, b) => a.ts - b.ts);
|
||||
const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
|
||||
|
||||
// Scroll selected annotation card into view
|
||||
useEffect(() => {
|
||||
@@ -113,24 +126,35 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select text to add annotations
|
||||
Select text or code lines to add annotations
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sortedAnnotations.map(ann => (
|
||||
<AnnotationCard
|
||||
key={ann.id}
|
||||
annotation={ann}
|
||||
isSelected={selectedId === ann.id}
|
||||
onSelect={() => onSelect(ann.id)}
|
||||
onDelete={() => onDelete(ann.id)}
|
||||
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(ann.id, updates) : undefined}
|
||||
/>
|
||||
{timelineEntries.map(entry => (
|
||||
entry.kind === 'plan' ? (
|
||||
<AnnotationCard
|
||||
key={entry.annotation.id}
|
||||
annotation={entry.annotation}
|
||||
isSelected={selectedId === entry.annotation.id}
|
||||
onSelect={() => onSelect(entry.annotation.id)}
|
||||
onDelete={() => onDelete(entry.annotation.id)}
|
||||
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<CodeAnnotationCard
|
||||
key={entry.annotation.id}
|
||||
annotation={entry.annotation}
|
||||
isSelected={selectedId === entry.annotation.id}
|
||||
onSelect={() => onSelectCodeAnnotation?.(entry.annotation.id)}
|
||||
onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)}
|
||||
onEdit={onEditCodeAnnotation ? (updates: Partial<CodeAnnotation>) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{editorAnnotations && editorAnnotations.length > 0 && (
|
||||
<>
|
||||
{sortedAnnotations.length > 0 && (
|
||||
{timelineEntries.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-2 pb-1">
|
||||
<div className="flex-1 border-t border-border/30" />
|
||||
<span className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">Editor</span>
|
||||
@@ -495,3 +519,170 @@ const AnnotationCard: React.FC<{
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeAnnotationCard: React.FC<{
|
||||
annotation: CodeAnnotation;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: (updates: Partial<CodeAnnotation>) => void;
|
||||
}> = ({ annotation, isSelected, onSelect, onDelete, onEdit }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(annotation.text || '');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) setEditText(annotation.text || '');
|
||||
}, [annotation.text, isEditing]);
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
onEdit?.({ text: editText });
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const lineRange = annotation.lineStart === annotation.lineEnd
|
||||
? `line ${annotation.lineStart}`
|
||||
: `lines ${annotation.lineStart}-${annotation.lineEnd}`;
|
||||
const fileName = annotation.filePath.split('/').pop() || annotation.filePath;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-annotation-id={annotation.id}
|
||||
onClick={onSelect}
|
||||
className={`group relative p-2.5 rounded-lg border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'bg-primary/5 border-primary/30 shadow-sm'
|
||||
: 'border-transparent hover:bg-muted/50 hover:border-border/50'
|
||||
}`}
|
||||
>
|
||||
{annotation.author && (
|
||||
<div className={`flex items-center gap-1.5 text-[10px] font-mono truncate mb-1.5 ${isCurrentUser(annotation.author) ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}>
|
||||
<svg className="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span className="truncate">{annotation.author}{isCurrentUser(annotation.author) && ' (me)'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-primary">
|
||||
<span className="p-1 rounded bg-primary/10">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide">Code</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/50">{formatTimestamp(annotation.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 transition-all">
|
||||
{onEdit && !isEditing && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-all"
|
||||
title="Edit annotation"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
|
||||
title="Delete annotation"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] font-mono text-muted-foreground bg-muted/50 rounded px-2 py-1.5 truncate" title={annotation.filePath}>
|
||||
{fileName} · {lineRange}
|
||||
</div>
|
||||
|
||||
{annotation.originalCode && (
|
||||
<div className="mt-1.5 text-[11px] font-mono text-muted-foreground bg-muted/30 rounded px-2 py-1.5 whitespace-pre-wrap max-h-24 overflow-y-auto">
|
||||
{annotation.originalCode}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
handleSaveEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setIsEditing(false);
|
||||
setEditText(annotation.text || '');
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full text-xs text-foreground/90 pl-2 border-l-2 border-primary/50 bg-background border border-border rounded px-2 py-1.5 resize-none focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
rows={Math.min(editText.split('\n').length + 1, 8)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveEdit();
|
||||
}}
|
||||
className="px-2 py-1 text-[10px] font-medium rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(false);
|
||||
setEditText(annotation.text || '');
|
||||
}}
|
||||
className="px-2 py-1 text-[10px] font-medium rounded bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
annotation.text && (
|
||||
<div className="mt-2 text-xs text-foreground/90 pl-2 border-l-2 border-primary/50 whitespace-pre-wrap">
|
||||
{annotation.text}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{annotation.images && annotation.images.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{annotation.images.map((img) => (
|
||||
<div key={img.path} className="text-center">
|
||||
<ImageThumbnail path={img.path} size="sm" showRemove={false} />
|
||||
<div className="text-[9px] text-muted-foreground truncate max-w-[3rem]" title={img.name}>{img.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TableBlock } from "./blocks/TableBlock";
|
||||
export const BlockRenderer: React.FC<{
|
||||
block: Block;
|
||||
onOpenLinkedDoc?: (path: string) => void;
|
||||
onOpenCodeFile?: (path: string) => void;
|
||||
imageBaseDir?: string;
|
||||
onImageClick?: (src: string, alt: string) => void;
|
||||
onToggleCheckbox?: (blockId: string, checked: boolean) => void;
|
||||
@@ -19,7 +20,7 @@ export const BlockRenderer: React.FC<{
|
||||
githubRepo?: string;
|
||||
headingAnchorId?: string;
|
||||
onNavigateAnchor?: (hash: string) => void;
|
||||
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => {
|
||||
}> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => {
|
||||
switch (block.type) {
|
||||
case 'heading': {
|
||||
const Tag = `h${block.level || 1}` as React.ElementType;
|
||||
@@ -35,7 +36,7 @@ export const BlockRenderer: React.FC<{
|
||||
data-block-id={block.id}
|
||||
data-block-type="heading"
|
||||
>
|
||||
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
|
||||
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +49,7 @@ export const BlockRenderer: React.FC<{
|
||||
kind={block.alertKind}
|
||||
body={block.content}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
imageBaseDir={imageBaseDir}
|
||||
onImageClick={onImageClick}
|
||||
githubRepo={githubRepo}
|
||||
@@ -65,7 +67,7 @@ export const BlockRenderer: React.FC<{
|
||||
>
|
||||
{paragraphs.map((para, i) => (
|
||||
<p key={i} className={i > 0 ? 'mt-2' : ''}>
|
||||
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
|
||||
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
|
||||
</p>
|
||||
))}
|
||||
</blockquote>
|
||||
@@ -94,7 +96,7 @@ export const BlockRenderer: React.FC<{
|
||||
onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined}
|
||||
/>
|
||||
<span className={`text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`}>
|
||||
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
|
||||
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -110,6 +112,7 @@ export const BlockRenderer: React.FC<{
|
||||
imageBaseDir={imageBaseDir}
|
||||
onImageClick={onImageClick}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
githubRepo={githubRepo}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
/>
|
||||
@@ -119,7 +122,7 @@ export const BlockRenderer: React.FC<{
|
||||
return <hr className="border-border/30 my-8" data-block-id={block.id} />;
|
||||
|
||||
case 'html':
|
||||
return <HtmlBlock block={block} imageBaseDir={imageBaseDir} onOpenLinkedDoc={onOpenLinkedDoc} onNavigateAnchor={onNavigateAnchor} />;
|
||||
return <HtmlBlock block={block} imageBaseDir={imageBaseDir} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={onNavigateAnchor} />;
|
||||
|
||||
case 'directive': {
|
||||
const kind = block.directiveKind || 'note';
|
||||
@@ -132,6 +135,7 @@ export const BlockRenderer: React.FC<{
|
||||
blockType="directive"
|
||||
kindAttribute={kind}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
imageBaseDir={imageBaseDir}
|
||||
onImageClick={onImageClick}
|
||||
githubRepo={githubRepo}
|
||||
@@ -146,7 +150,7 @@ export const BlockRenderer: React.FC<{
|
||||
className="mb-4 leading-relaxed text-foreground/90 text-[15px]"
|
||||
data-block-id={block.id}
|
||||
>
|
||||
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
|
||||
<InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { File, type LineAnnotation } from '@pierre/diffs/react';
|
||||
import type { SelectedLineRange as PierreSelectedLineRange, LineEventBaseProps } from '@pierre/diffs';
|
||||
import { PopoutDialog } from './PopoutDialog';
|
||||
import { useTheme } from './ThemeProvider';
|
||||
import { CommentPopover } from './CommentPopover';
|
||||
import { ImageThumbnail } from './ImageThumbnail';
|
||||
import type { CodeAnnotation, ImageAttachment } from '../types';
|
||||
|
||||
export interface CodeFileAnnotationInput {
|
||||
filePath: string;
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
text: string;
|
||||
images?: ImageAttachment[];
|
||||
originalCode: string;
|
||||
}
|
||||
|
||||
interface CodeFilePopoutProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
filepath: string;
|
||||
contents: string;
|
||||
prerenderedHTML?: string;
|
||||
annotations?: CodeAnnotation[];
|
||||
selectedAnnotationId?: string | null;
|
||||
onAddAnnotation?: (annotation: CodeFileAnnotationInput) => void;
|
||||
onEditAnnotation?: (id: string, updates: Partial<CodeAnnotation>) => void;
|
||||
onDeleteAnnotation?: (id: string) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
container?: HTMLElement | null;
|
||||
}
|
||||
|
||||
interface PendingComment {
|
||||
range: { start: number; end: number };
|
||||
contextText: string;
|
||||
originalCode: string;
|
||||
anchorEl?: HTMLElement;
|
||||
anchorRect?: DOMRect;
|
||||
}
|
||||
|
||||
const gutterButtonStyle: React.CSSProperties = {
|
||||
appearance: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '1lh',
|
||||
height: '1lh',
|
||||
fontSize: 'var(--diffs-font-size, 13px)',
|
||||
lineHeight: 'var(--diffs-line-height, 20px)',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'var(--diffs-modified-base)',
|
||||
color: 'var(--diffs-bg)',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
zIndex: 4,
|
||||
padding: 0,
|
||||
marginRight: 'calc(1ch - 1lh)',
|
||||
};
|
||||
|
||||
function getThemeColors(): { bg: string; fg: string } {
|
||||
try {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
bg: styles.getPropertyValue('--background').trim(),
|
||||
fg: styles.getPropertyValue('--foreground').trim(),
|
||||
};
|
||||
} catch {
|
||||
return { bg: '', fg: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function buildPierreCSS(mode: 'dark' | 'light', bg: string, fg: string): string {
|
||||
if (!bg || !fg) return '';
|
||||
return `
|
||||
:host {
|
||||
color-scheme: ${mode};
|
||||
height: 100% !important;
|
||||
}
|
||||
:host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] {
|
||||
--diffs-bg: ${bg} !important;
|
||||
--diffs-fg: ${fg} !important;
|
||||
--diffs-dark-bg: ${bg};
|
||||
--diffs-light-bg: ${bg};
|
||||
--diffs-dark: ${fg};
|
||||
--diffs-light: ${fg};
|
||||
}
|
||||
pre, code { background-color: ${bg} !important; }
|
||||
[data-column-number] { background-color: ${bg} !important; }
|
||||
[data-file] { height: 100% !important; }
|
||||
[data-code] { height: 100% !important; overflow-y: auto !important; }
|
||||
[data-line] { cursor: pointer; }
|
||||
`;
|
||||
}
|
||||
|
||||
function getLineSlice(contents: string, start: number, end: number): string {
|
||||
return contents
|
||||
.split('\n')
|
||||
.slice(Math.max(0, start - 1), Math.max(0, end))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function lineLabel(start: number, end: number): string {
|
||||
return start === end ? `line ${start}` : `lines ${start}-${end}`;
|
||||
}
|
||||
|
||||
function getLineNumberFromSelectionNode(node: Node | null): number | null {
|
||||
let current: Node | null = node;
|
||||
if (current?.nodeType === Node.TEXT_NODE) current = current.parentNode;
|
||||
|
||||
while (current) {
|
||||
if (current instanceof HTMLElement) {
|
||||
const line = current.closest('[data-line]')?.getAttribute('data-line');
|
||||
if (line) {
|
||||
const parsed = Number(line);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
}
|
||||
current = current.parentNode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPierreSelection(root: HTMLElement | null): Selection | null {
|
||||
const shadowRoot = root?.querySelector('diffs-container')?.shadowRoot;
|
||||
const shadowSelection = (shadowRoot as (ShadowRoot & { getSelection?: () => Selection | null }) | null)
|
||||
?.getSelection?.();
|
||||
return shadowSelection && !shadowSelection.isCollapsed
|
||||
? shadowSelection
|
||||
: window.getSelection();
|
||||
}
|
||||
|
||||
const CodeInlineAnnotation: React.FC<{
|
||||
annotation: CodeAnnotation;
|
||||
isSelected: boolean;
|
||||
onSelect?: (id: string | null) => void;
|
||||
onEdit?: (id: string, updates: Partial<CodeAnnotation>) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}> = ({ annotation, isSelected, onSelect, onEdit, onDelete }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(annotation.text ?? '');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) setEditText(annotation.text ?? '');
|
||||
}, [annotation.text, isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const save = () => {
|
||||
onEdit?.(annotation.id, { text: editText });
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-code-annotation-id={annotation.id}
|
||||
onClick={() => onSelect?.(annotation.id)}
|
||||
className={`group my-2 mx-3 rounded-lg border px-3 py-2 text-xs shadow-sm cursor-pointer transition-colors ${
|
||||
isSelected ? 'border-primary/50' : 'border-border hover:border-border/80'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isSelected ? 'color-mix(in oklab, var(--primary) 12%, var(--popover))' : 'var(--popover)',
|
||||
color: 'var(--foreground)',
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<span className="font-semibold text-primary">Comment</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono normal-case text-foreground">
|
||||
{lineLabel(annotation.lineStart, annotation.lineEnd)}
|
||||
</span>
|
||||
{annotation.author && <span className="truncate normal-case">by {annotation.author}</span>}
|
||||
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
|
||||
{onEdit && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Edit comment"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(annotation.id);
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Delete comment"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setIsEditing(false);
|
||||
setEditText(annotation.text ?? '');
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}}
|
||||
rows={Math.min(editText.split('\n').length + 1, 8)}
|
||||
className="w-full resize-none rounded border border-border bg-background px-2 py-1.5 text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
save();
|
||||
}}
|
||||
className="rounded bg-primary px-2 py-1 text-[10px] font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(false);
|
||||
setEditText(annotation.text ?? '');
|
||||
}}
|
||||
className="rounded bg-muted px-2 py-1 text-[10px] font-medium text-muted-foreground hover:bg-muted/80"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
annotation.text && (
|
||||
<div className="mt-1.5 whitespace-pre-wrap border-l-2 border-primary/50 pl-2 text-foreground/90">
|
||||
{annotation.text}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{annotation.images && annotation.images.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{annotation.images.map((img) => (
|
||||
<div key={img.path} className="text-center">
|
||||
<ImageThumbnail path={img.path} size="sm" showRemove={false} />
|
||||
<div className="max-w-[3rem] truncate text-[9px] text-muted-foreground" title={img.name}>
|
||||
{img.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
filepath,
|
||||
contents,
|
||||
prerenderedHTML,
|
||||
annotations = [],
|
||||
selectedAnnotationId,
|
||||
onAddAnnotation,
|
||||
onEditAnnotation,
|
||||
onDeleteAnnotation,
|
||||
onSelectAnnotation,
|
||||
container,
|
||||
}) => {
|
||||
const { resolvedMode } = useTheme();
|
||||
const mode = resolvedMode ?? 'dark';
|
||||
const colors = getThemeColors();
|
||||
const [pierreTheme, setPierreTheme] = useState(() => ({
|
||||
type: mode as 'dark' | 'light',
|
||||
css: buildPierreCSS(mode, colors.bg, colors.fg),
|
||||
}));
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [pendingComment, setPendingComment] = useState<PendingComment | null>(null);
|
||||
const fileAreaRef = useRef<HTMLDivElement>(null);
|
||||
const lastPointerRectRef = useRef<DOMRect | null>(null);
|
||||
const suppressLineClickUntilRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const c = getThemeColors();
|
||||
setPierreTheme({
|
||||
type: mode,
|
||||
css: buildPierreCSS(mode, c.bg, c.fg),
|
||||
});
|
||||
});
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingComment(null);
|
||||
}, [filepath]);
|
||||
|
||||
const displayName = filepath.split('/').pop() || filepath;
|
||||
const relativePath = filepath.replace(/.*\/(?=.*\/)/, '');
|
||||
const lineCount = useMemo(() => contents.split('\n').length, [contents]);
|
||||
const selectedCodeAnnotation = useMemo(
|
||||
() => annotations.find((ann) => ann.id === selectedAnnotationId),
|
||||
[annotations, selectedAnnotationId],
|
||||
);
|
||||
|
||||
// TODO: add token-level annotation support (charStart/charEnd) — for now only line-scope
|
||||
const lineAnnotations = useMemo((): LineAnnotation<CodeAnnotation>[] => {
|
||||
return annotations
|
||||
.filter((ann) => (ann.scope ?? 'line') === 'line')
|
||||
.map((ann) => ({
|
||||
lineNumber: ann.lineEnd,
|
||||
metadata: ann,
|
||||
}));
|
||||
}, [annotations]);
|
||||
|
||||
const selectedLines = useMemo((): PierreSelectedLineRange | null => {
|
||||
if (pendingComment) {
|
||||
return { start: pendingComment.range.start, end: pendingComment.range.end };
|
||||
}
|
||||
if (selectedCodeAnnotation) {
|
||||
return { start: selectedCodeAnnotation.lineStart, end: selectedCodeAnnotation.lineEnd };
|
||||
}
|
||||
return null;
|
||||
}, [pendingComment, selectedCodeAnnotation]);
|
||||
const effectivePrerenderedHTML = lineAnnotations.length === 0 ? prerenderedHTML : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAnnotationId || !fileAreaRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
fileAreaRef.current
|
||||
?.querySelector(`[data-code-annotation-id="${selectedAnnotationId}"]`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedAnnotationId, filepath]);
|
||||
|
||||
const openCommentForRange = useCallback((
|
||||
range: { start: number; end: number },
|
||||
anchorEl?: HTMLElement,
|
||||
anchorRect?: DOMRect,
|
||||
) => {
|
||||
const start = Math.min(range.start, range.end);
|
||||
const end = Math.max(range.start, range.end);
|
||||
setPendingComment({
|
||||
range: { start, end },
|
||||
anchorEl,
|
||||
anchorRect: anchorRect ?? anchorEl?.getBoundingClientRect() ?? lastPointerRectRef.current ?? undefined,
|
||||
contextText: `${relativePath} ${lineLabel(start, end)}`,
|
||||
originalCode: getLineSlice(contents, start, end),
|
||||
});
|
||||
}, [contents, relativePath]);
|
||||
|
||||
const openCommentForBrowserSelection = useCallback(() => {
|
||||
if (!onAddAnnotation) return;
|
||||
|
||||
const selection = getPierreSelection(fileAreaRef.current);
|
||||
const selectedText = selection?.toString();
|
||||
if (!selection || selection.isCollapsed || !selectedText?.trim()) return;
|
||||
|
||||
const anchorLine = getLineNumberFromSelectionNode(selection.anchorNode);
|
||||
const focusLine = getLineNumberFromSelectionNode(selection.focusNode);
|
||||
if (anchorLine == null || focusLine == null) return;
|
||||
|
||||
openCommentForRange(
|
||||
{ start: anchorLine, end: focusLine },
|
||||
undefined,
|
||||
lastPointerRectRef.current
|
||||
?? (selection.rangeCount > 0 ? selection.getRangeAt(0).getBoundingClientRect() : undefined),
|
||||
);
|
||||
selection.removeAllRanges();
|
||||
}, [onAddAnnotation, openCommentForRange]);
|
||||
|
||||
const renderAnnotation = useCallback((annotation: LineAnnotation<CodeAnnotation>) => {
|
||||
if (!annotation.metadata) return null;
|
||||
return (
|
||||
<CodeInlineAnnotation
|
||||
annotation={annotation.metadata}
|
||||
isSelected={selectedAnnotationId === annotation.metadata.id}
|
||||
onSelect={onSelectAnnotation}
|
||||
onEdit={onEditAnnotation}
|
||||
onDelete={onDeleteAnnotation}
|
||||
/>
|
||||
);
|
||||
}, [onDeleteAnnotation, onEditAnnotation, onSelectAnnotation, selectedAnnotationId]);
|
||||
|
||||
const renderGutterUtility = useCallback((getHoveredLine: () => { lineNumber: number } | undefined) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="hover-add-comment"
|
||||
style={gutterButtonStyle}
|
||||
title="Add code comment"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.filter = 'brightness(1.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.filter = '';
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const line = getHoveredLine();
|
||||
if (!line) return;
|
||||
openCommentForRange({ start: line.lineNumber, end: line.lineNumber }, e.currentTarget);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
);
|
||||
}, [openCommentForRange]);
|
||||
|
||||
const handleLineSelectionEnd = useCallback((range: PierreSelectedLineRange | null) => {
|
||||
if (!onAddAnnotation) return;
|
||||
if (!range) return;
|
||||
if (range.start !== range.end) {
|
||||
suppressLineClickUntilRef.current = Date.now() + 300;
|
||||
}
|
||||
openCommentForRange({ start: range.start, end: range.end }, undefined, lastPointerRectRef.current ?? undefined);
|
||||
}, [onAddAnnotation, openCommentForRange]);
|
||||
|
||||
const handleLineClick = useCallback((props: LineEventBaseProps & { event: PointerEvent }) => {
|
||||
if (!onAddAnnotation) return;
|
||||
if (Date.now() < suppressLineClickUntilRef.current) return;
|
||||
openCommentForRange(
|
||||
{ start: props.lineNumber, end: props.lineNumber },
|
||||
undefined,
|
||||
props.lineElement.getBoundingClientRect(),
|
||||
);
|
||||
}, [onAddAnnotation, openCommentForRange]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(contents);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoutDialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={displayName}
|
||||
container={container}
|
||||
className="w-[calc(100vw-4rem)] max-w-[min(calc(100vw-4rem),1500px)] h-[calc(100vh-4rem)]"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-5 pt-4 pb-3 pr-12">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<svg className="w-4 h-4 flex-shrink-0 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-foreground truncate" title={filepath}>
|
||||
{relativePath}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{lineCount} lines
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title={copied ? 'Copied!' : 'Copy file contents'}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
copied ? 'text-success' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/30" />
|
||||
<div
|
||||
ref={fileAreaRef}
|
||||
className="flex-1 min-h-0"
|
||||
onPointerMove={(e) => {
|
||||
lastPointerRectRef.current = new DOMRect(e.clientX, e.clientY, 0, 0);
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
requestAnimationFrame(openCommentForBrowserSelection);
|
||||
}}
|
||||
>
|
||||
<File
|
||||
key={filepath}
|
||||
file={{ name: displayName, contents }}
|
||||
prerenderedHTML={effectivePrerenderedHTML}
|
||||
className="h-full"
|
||||
lineAnnotations={lineAnnotations}
|
||||
selectedLines={selectedLines}
|
||||
renderAnnotation={renderAnnotation}
|
||||
renderGutterUtility={renderGutterUtility}
|
||||
style={{
|
||||
'--diffs-dark-bg': colors.bg,
|
||||
'--diffs-light-bg': colors.bg,
|
||||
'--diffs-dark': colors.fg,
|
||||
'--diffs-light': colors.fg,
|
||||
} as React.CSSProperties}
|
||||
options={{
|
||||
themeType: pierreTheme.type,
|
||||
unsafeCSS: pierreTheme.css,
|
||||
overflow: 'scroll',
|
||||
disableFileHeader: true,
|
||||
enableLineSelection: true,
|
||||
enableGutterUtility: !!onAddAnnotation,
|
||||
lineHoverHighlight: onAddAnnotation ? 'line' : 'disabled',
|
||||
onLineClick: handleLineClick,
|
||||
onLineSelectionEnd: handleLineSelectionEnd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{pendingComment && onAddAnnotation && (
|
||||
<CommentPopover
|
||||
anchorEl={pendingComment.anchorEl}
|
||||
anchorRect={pendingComment.anchorRect}
|
||||
contextText={pendingComment.contextText}
|
||||
isGlobal={false}
|
||||
onSubmit={(text, images) => {
|
||||
onAddAnnotation({
|
||||
filePath: filepath,
|
||||
lineStart: pendingComment.range.start,
|
||||
lineEnd: pendingComment.range.end,
|
||||
text,
|
||||
images,
|
||||
originalCode: pendingComment.originalCode,
|
||||
});
|
||||
setPendingComment(null);
|
||||
}}
|
||||
onClose={() => setPendingComment(null)}
|
||||
/>
|
||||
)}
|
||||
</PopoutDialog>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,9 @@ import { useDraggable } from '../hooks/useDraggable';
|
||||
|
||||
interface CommentPopoverProps {
|
||||
/** Element to anchor the popover near (re-reads position on scroll) */
|
||||
anchorEl: HTMLElement;
|
||||
anchorEl?: HTMLElement;
|
||||
/** Static viewport rect to anchor near when no stable DOM element exists */
|
||||
anchorRect?: DOMRect;
|
||||
/** Truncated selected text shown in header, or empty for global */
|
||||
contextText: string;
|
||||
/** Whether this is a global comment */
|
||||
@@ -40,6 +42,7 @@ function computePosition(anchorRect: DOMRect): { top: number; left: number; flip
|
||||
|
||||
export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
||||
anchorEl,
|
||||
anchorRect,
|
||||
contextText,
|
||||
isGlobal,
|
||||
initialText = '',
|
||||
@@ -55,7 +58,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
||||
const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef);
|
||||
|
||||
// Reset drag when anchor changes (new annotation) or mode switches
|
||||
useEffect(() => { resetDrag(); }, [anchorEl, resetDrag]);
|
||||
useEffect(() => { resetDrag(); }, [anchorEl, anchorRect, resetDrag]);
|
||||
useEffect(() => { if (mode === 'popover') resetDrag(); }, [mode, resetDrag]);
|
||||
|
||||
// Track anchor position on scroll/resize (popover mode only, not after user drag)
|
||||
@@ -63,7 +66,8 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
||||
if (mode !== 'popover' || wasDragged) return;
|
||||
|
||||
const update = () => {
|
||||
setPosition(computePosition(anchorEl.getBoundingClientRect()));
|
||||
const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
|
||||
if (rect) setPosition(computePosition(rect));
|
||||
};
|
||||
|
||||
update();
|
||||
@@ -73,7 +77,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
||||
window.removeEventListener('scroll', update, true);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, [anchorEl, mode, wasDragged]);
|
||||
}, [anchorEl, anchorRect, mode, wasDragged]);
|
||||
|
||||
// Focus textarea on mount and mode changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { isCodeFilePath } from "@plannotator/shared/code-file";
|
||||
import { transformPlainText } from "../utils/inlineTransforms";
|
||||
import { getImageSrc } from "./ImageThumbnail";
|
||||
|
||||
@@ -85,11 +86,12 @@ function emitPlainTextWithBareUrls(
|
||||
export const InlineMarkdown: React.FC<{
|
||||
text: string;
|
||||
onOpenLinkedDoc?: (path: string) => void;
|
||||
onOpenCodeFile?: (path: string) => void;
|
||||
onNavigateAnchor?: (hash: string) => void;
|
||||
imageBaseDir?: string;
|
||||
onImageClick?: (src: string, alt: string) => void;
|
||||
githubRepo?: string;
|
||||
}> = ({ text, onOpenLinkedDoc, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
|
||||
}> = ({ text, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, imageBaseDir, onImageClick, githubRepo }) => {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let remaining = text;
|
||||
let key = 0;
|
||||
@@ -180,6 +182,7 @@ export const InlineMarkdown: React.FC<{
|
||||
onImageClick={onImageClick}
|
||||
text={match[1]}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
/>
|
||||
@@ -201,6 +204,7 @@ export const InlineMarkdown: React.FC<{
|
||||
onImageClick={onImageClick}
|
||||
text={match[1]}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
/>
|
||||
@@ -222,6 +226,7 @@ export const InlineMarkdown: React.FC<{
|
||||
onImageClick={onImageClick}
|
||||
text={match[1]}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
/>
|
||||
@@ -242,6 +247,7 @@ export const InlineMarkdown: React.FC<{
|
||||
onImageClick={onImageClick}
|
||||
text={match[1]}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
/>
|
||||
@@ -263,6 +269,7 @@ export const InlineMarkdown: React.FC<{
|
||||
onImageClick={onImageClick}
|
||||
text={match[1]}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
/>
|
||||
@@ -505,7 +512,9 @@ export const InlineMarkdown: React.FC<{
|
||||
/\.(mdx?|html?)(#.*)?$/i.test(linkUrl) &&
|
||||
!linkUrl.startsWith("http://") &&
|
||||
!linkUrl.startsWith("https://");
|
||||
const isCodeFile = !isLocalDoc && isCodeFilePath(linkUrl);
|
||||
const linkedDocPath = isLocalDoc ? linkUrl.replace(/#.*$/, '') : linkUrl;
|
||||
const codeFilePath = isCodeFile ? linkUrl.replace(/#.*$/, '') : linkUrl;
|
||||
const isInPageAnchor = safeLinkUrl.startsWith('#');
|
||||
|
||||
if (isInPageAnchor) {
|
||||
@@ -551,6 +560,31 @@ export const InlineMarkdown: React.FC<{
|
||||
</svg>
|
||||
</a>,
|
||||
);
|
||||
} else if (isCodeFile && onOpenCodeFile) {
|
||||
parts.push(
|
||||
<a
|
||||
key={key++}
|
||||
href={safeLinkUrl}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onOpenCodeFile(codeFilePath);
|
||||
}}
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
|
||||
title={`View: ${linkUrl}`}
|
||||
>
|
||||
{linkText}
|
||||
<svg
|
||||
className="w-3 h-3 opacity-50 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
</a>,
|
||||
);
|
||||
} else if (isLocalDoc) {
|
||||
// No handler — render as plain link (e.g., in shared/portal views)
|
||||
parts.push(
|
||||
@@ -590,6 +624,7 @@ export const InlineMarkdown: React.FC<{
|
||||
key={key++}
|
||||
text={before}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
imageBaseDir={imageBaseDir}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
|
||||
const ANNOTATION_SELECTORS = [
|
||||
'.annotation-toolbar',
|
||||
'[data-comment-popover="true"]',
|
||||
'[data-floating-picker="true"]',
|
||||
];
|
||||
|
||||
interface PopoutDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
container?: HTMLElement | null;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
/** Extra data attributes to spread onto Dialog.Content */
|
||||
dataAttributes?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const PopoutDialog: React.FC<PopoutDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
container,
|
||||
className,
|
||||
children,
|
||||
dataAttributes,
|
||||
}) => {
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as Element;
|
||||
if (ANNOTATION_SELECTORS.some((sel) => target.closest(sel))) return;
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleInteractOutside = useCallback(
|
||||
(e: Event) => {
|
||||
const target = e.target as Element | null;
|
||||
if (!target) return;
|
||||
if (ANNOTATION_SELECTORS.some((sel) => target.closest(sel))) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => { if (!next) onClose(); }}
|
||||
modal={false}
|
||||
>
|
||||
<Dialog.Portal container={container ?? undefined}>
|
||||
{/* Radix ignores Dialog.Overlay when modal={false}, so we use a plain
|
||||
div for the backdrop. This gives us the dark scrim + blur while
|
||||
keeping annotation toolbars (which portal outside the dialog)
|
||||
interactive. */}
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[2px]"
|
||||
onClick={handleBackdropClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Dialog.Content
|
||||
className={`fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden ${className ?? 'w-[calc(100vw-4rem)] max-w-[min(calc(100vw-4rem),1500px)] max-h-[calc(100vh-4rem)]'}`}
|
||||
data-popout="true"
|
||||
aria-describedby={undefined}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={handleInteractOutside}
|
||||
{...dataAttributes}
|
||||
>
|
||||
<Dialog.Title className="sr-only">{title}</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute top-3 right-3 z-20 p-1.5 rounded-md text-muted-foreground/70 hover:bg-muted hover:text-foreground transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
|
||||
{children}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
@@ -64,6 +64,7 @@ interface ViewerProps {
|
||||
repoInfo?: { display: string; branch?: string; host?: string } | null;
|
||||
stickyActions?: boolean;
|
||||
onOpenLinkedDoc?: (path: string) => void;
|
||||
onOpenCodeFile?: (path: string) => void;
|
||||
imageBaseDir?: string;
|
||||
linkedDocInfo?: { filepath: string; onBack: () => void; label?: string; backLabel?: string } | null;
|
||||
// Plan diff props
|
||||
@@ -153,6 +154,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
showDemoBadge,
|
||||
maxWidth,
|
||||
onOpenLinkedDoc,
|
||||
onOpenCodeFile,
|
||||
linkedDocInfo,
|
||||
imageBaseDir,
|
||||
copyLabel,
|
||||
@@ -611,6 +613,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
block={block}
|
||||
orderedIndex={indices[i]}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onToggleCheckbox={onToggleCheckbox}
|
||||
checkboxOverrides={checkboxOverrides}
|
||||
githubRepo={repoInfo?.display}
|
||||
@@ -632,6 +635,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
imageBaseDir={imageBaseDir}
|
||||
onImageClick={(src, alt) => setLightbox({ src, alt })}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
githubRepo={repoInfo?.display}
|
||||
onNavigateAnchor={scrollToAnchor}
|
||||
onHover={(element) => {
|
||||
@@ -685,7 +689,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
|
||||
/>
|
||||
) : (
|
||||
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
|
||||
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -781,6 +785,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
imageBaseDir={imageBaseDir}
|
||||
onImageClick={(src, alt) => setLightbox({ src, alt })}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
githubRepo={repoInfo?.display}
|
||||
onNavigateAnchor={scrollToAnchor}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ interface AlertBlockProps {
|
||||
kind: AlertKind;
|
||||
body: string;
|
||||
onOpenLinkedDoc?: (path: string) => void;
|
||||
onOpenCodeFile?: (path: string) => void;
|
||||
imageBaseDir?: string;
|
||||
onImageClick?: (src: string, alt: string) => void;
|
||||
githubRepo?: string;
|
||||
@@ -38,7 +39,7 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
|
||||
};
|
||||
|
||||
export const AlertBlock: React.FC<AlertBlockProps> = ({
|
||||
blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
|
||||
blockId, kind, body, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
@@ -51,7 +52,7 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
|
||||
<Icon kind={kind} />
|
||||
<span>{TITLE[kind]}</span>
|
||||
</div>
|
||||
{renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, onNavigateAnchor, githubRepo })}
|
||||
{renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ interface CalloutProps {
|
||||
blockType: 'alert' | 'directive';
|
||||
kindAttribute: string;
|
||||
onOpenLinkedDoc?: (path: string) => void;
|
||||
onOpenCodeFile?: (path: string) => void;
|
||||
imageBaseDir?: string;
|
||||
onImageClick?: (src: string, alt: string) => void;
|
||||
githubRepo?: string;
|
||||
@@ -23,6 +24,7 @@ export const Callout: React.FC<CalloutProps> = ({
|
||||
blockType,
|
||||
kindAttribute,
|
||||
onOpenLinkedDoc,
|
||||
onOpenCodeFile,
|
||||
imageBaseDir,
|
||||
onImageClick,
|
||||
githubRepo,
|
||||
@@ -49,6 +51,7 @@ export const Callout: React.FC<CalloutProps> = ({
|
||||
imageBaseDir,
|
||||
onImageClick,
|
||||
onOpenLinkedDoc,
|
||||
onOpenCodeFile,
|
||||
onNavigateAnchor,
|
||||
githubRepo,
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { isCodeFilePath } from "@plannotator/shared/code-file";
|
||||
import { Block } from "../../types";
|
||||
import { sanitizeBlockHtml } from "../../utils/sanitizeHtml";
|
||||
import { getImageSrc } from "../ImageThumbnail";
|
||||
@@ -7,6 +8,7 @@ interface HtmlBlockProps {
|
||||
block: Block;
|
||||
imageBaseDir?: string;
|
||||
onOpenLinkedDoc?: (path: string) => void;
|
||||
onOpenCodeFile?: (path: string) => void;
|
||||
onNavigateAnchor?: (hash: string) => void;
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ function rewriteRelativeRefs(
|
||||
root: HTMLElement,
|
||||
imageBaseDir?: string,
|
||||
onOpenLinkedDoc?: (path: string) => void,
|
||||
onOpenCodeFile?: (path: string) => void,
|
||||
onNavigateAnchor?: (hash: string) => void,
|
||||
): (() => void) {
|
||||
const cleanups: (() => void)[] = [];
|
||||
@@ -56,6 +59,15 @@ function rewriteRelativeRefs(
|
||||
cleanups.push(() => a.removeEventListener('click', handler));
|
||||
return;
|
||||
}
|
||||
if (onOpenCodeFile && isCodeFilePath(href)) {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onOpenCodeFile(href.replace(/#.*$/, ''));
|
||||
};
|
||||
a.addEventListener('click', handler);
|
||||
cleanups.push(() => a.removeEventListener('click', handler));
|
||||
return;
|
||||
}
|
||||
if (onOpenLinkedDoc && /\.(mdx?|html?)(#.*)?$/i.test(href)) {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
@@ -75,7 +87,7 @@ function rewriteRelativeRefs(
|
||||
// re-set on every parent re-render would collapse any open <details> the
|
||||
// user just opened. Paired with React.memo below so the component itself
|
||||
// stops re-rendering unless the block content actually changes.
|
||||
const HtmlBlockImpl: React.FC<HtmlBlockProps> = ({ block, imageBaseDir, onOpenLinkedDoc, onNavigateAnchor }) => {
|
||||
const HtmlBlockImpl: React.FC<HtmlBlockProps> = ({ block, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const sanitized = React.useMemo(
|
||||
() => sanitizeBlockHtml(block.content),
|
||||
@@ -86,9 +98,9 @@ const HtmlBlockImpl: React.FC<HtmlBlockProps> = ({ block, imageBaseDir, onOpenLi
|
||||
if (ref.current.innerHTML !== sanitized) {
|
||||
ref.current.innerHTML = sanitized;
|
||||
}
|
||||
const cleanup = rewriteRelativeRefs(ref.current, imageBaseDir, onOpenLinkedDoc, onNavigateAnchor);
|
||||
const cleanup = rewriteRelativeRefs(ref.current, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor);
|
||||
return cleanup;
|
||||
}, [sanitized, imageBaseDir, onOpenLinkedDoc, onNavigateAnchor]);
|
||||
}, [sanitized, imageBaseDir, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor]);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -105,5 +117,6 @@ export const HtmlBlock = React.memo(
|
||||
prev.block.content === next.block.content &&
|
||||
prev.imageBaseDir === next.imageBaseDir &&
|
||||
prev.onOpenLinkedDoc === next.onOpenLinkedDoc &&
|
||||
prev.onOpenCodeFile === next.onOpenCodeFile &&
|
||||
prev.onNavigateAnchor === next.onNavigateAnchor,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ interface TableBlockProps {
|
||||
onHover?: (element: HTMLElement) => void;
|
||||
onLeave?: () => void;
|
||||
onOpenLinkedDoc?: (path: string) => void;
|
||||
onOpenCodeFile?: (path: string) => void;
|
||||
onNavigateAnchor?: (hash: string) => void;
|
||||
imageBaseDir?: string;
|
||||
onImageClick?: (src: string, alt: string) => void;
|
||||
@@ -81,6 +82,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
|
||||
onHover,
|
||||
onLeave,
|
||||
onOpenLinkedDoc,
|
||||
onOpenCodeFile,
|
||||
onNavigateAnchor,
|
||||
imageBaseDir,
|
||||
onImageClick,
|
||||
@@ -111,6 +113,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
|
||||
onImageClick={onImageClick}
|
||||
text={header}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
/>
|
||||
@@ -128,6 +131,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
|
||||
onImageClick={onImageClick}
|
||||
text={cell}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
@@ -12,25 +11,24 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import { Block } from '../../types';
|
||||
import { InlineMarkdown } from '../InlineMarkdown';
|
||||
import { PopoutDialog } from '../PopoutDialog';
|
||||
import { parseTableContent, buildCsvFromRows, buildMarkdownTable } from './TableBlock';
|
||||
|
||||
interface TablePopoutProps {
|
||||
block: Block;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Portal target — pass Viewer's annotation containerRef so annotation */
|
||||
/** hooks can walk into the popout's text nodes. Null falls back to body. */
|
||||
/** Portal target — pass Viewer's annotation containerRef so annotation
|
||||
* hooks can walk into the popout's text nodes. Null falls back to body. */
|
||||
container?: HTMLElement | null;
|
||||
onOpenLinkedDoc?: (path: string) => void;
|
||||
onOpenCodeFile?: (path: string) => void;
|
||||
onNavigateAnchor?: (hash: string) => void;
|
||||
imageBaseDir?: string;
|
||||
onImageClick?: (src: string, alt: string) => void;
|
||||
githubRepo?: string;
|
||||
}
|
||||
|
||||
// A row is a dict of columnId → cell text. Column ids are derived from the
|
||||
// header label (or index fallback) so the data shape matches what TanStack
|
||||
// Table expects for an accessor-based column definition.
|
||||
type Row = Record<string, string>;
|
||||
|
||||
const TablePopoutImpl: React.FC<TablePopoutProps> = ({
|
||||
@@ -39,6 +37,7 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
|
||||
onClose,
|
||||
container,
|
||||
onOpenLinkedDoc,
|
||||
onOpenCodeFile,
|
||||
onNavigateAnchor,
|
||||
imageBaseDir,
|
||||
onImageClick,
|
||||
@@ -46,8 +45,6 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
|
||||
}) => {
|
||||
const { headers, rows } = useMemo(() => parseTableContent(block.content), [block.content]);
|
||||
|
||||
// Build stable column ids from headers — duplicates get suffixed so
|
||||
// TanStack Table doesn't collide on accessor keys.
|
||||
const columnIds = useMemo(() => {
|
||||
const seen = new Map<string, number>();
|
||||
return headers.map((h, i) => {
|
||||
@@ -82,6 +79,7 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
|
||||
onImageClick={onImageClick}
|
||||
text={info.getValue()}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
/>
|
||||
@@ -95,8 +93,6 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
|
||||
const [copiedMd, setCopiedMd] = useState(false);
|
||||
const [copiedCsv, setCopiedCsv] = useState(false);
|
||||
|
||||
// Copy reflects what the user currently sees — filter + sort applied.
|
||||
// Read is one-shot on click; no subscription, no derived state to sync.
|
||||
const getVisibleRowsData = (): string[][] =>
|
||||
table.getRowModel().rows.map((row) =>
|
||||
columnIds.map((id) => row.getValue<string>(id) ?? ''),
|
||||
@@ -138,151 +134,118 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
|
||||
const totalRows = data.length;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(next) => { if (!next) onClose(); }} modal={false}>
|
||||
<Dialog.Portal container={container ?? undefined}>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-1/2 z-50 w-[calc(100vw-4rem)] max-w-[min(calc(100vw-4rem),1500px)] max-h-[calc(100vh-4rem)] -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden"
|
||||
data-block-id={block.id}
|
||||
data-popout="true"
|
||||
aria-describedby={undefined}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
// With modal={false}, Radix treats any click outside Dialog.Content
|
||||
// as dismissal. Our annotation stack (toolbar, comment popover,
|
||||
// quick-label picker) portals to document.body — clicks on them
|
||||
// are outside the dialog DOM but logically part of this session.
|
||||
// Keep the dialog open when the interaction lands on any of them.
|
||||
const target = e.target as Node | null;
|
||||
if (!target || !(target instanceof Element)) return;
|
||||
if (
|
||||
target.closest('.annotation-toolbar') ||
|
||||
target.closest('[data-comment-popover="true"]') ||
|
||||
target.closest('[data-floating-picker="true"]')
|
||||
) {
|
||||
e.preventDefault();
|
||||
<PopoutDialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Table"
|
||||
container={container}
|
||||
dataAttributes={{ 'data-block-id': block.id }}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-5 pt-4 pb-3 pr-12">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M10 18a8 8 0 100-16 8 8 0 000 16z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
placeholder="Filter rows…"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted/40 border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{visibleRows.length === totalRows
|
||||
? `${totalRows} row${totalRows === 1 ? '' : 's'}`
|
||||
: `${visibleRows.length} of ${totalRows}`}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
title={
|
||||
copiedMd
|
||||
? 'Copied!'
|
||||
: visibleRows.length === totalRows
|
||||
? 'Copy as markdown'
|
||||
: `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as markdown`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Title className="sr-only">Table</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute top-3 right-3 z-20 p-1.5 rounded-md text-muted-foreground/70 hover:bg-muted hover:text-foreground transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
copiedMd ? 'text-success' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{copiedMd ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyCsv}
|
||||
title={
|
||||
copiedCsv
|
||||
? 'Copied as CSV!'
|
||||
: visibleRows.length === totalRows
|
||||
? 'Copy as CSV'
|
||||
: `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as CSV`
|
||||
}
|
||||
className={`px-2 py-1 rounded-md text-[10px] font-bold tracking-tight uppercase leading-none transition-colors ${
|
||||
copiedCsv ? 'text-success' : 'text-primary hover:bg-primary/10'
|
||||
}`}
|
||||
>
|
||||
{copiedCsv ? '✓' : 'CSV'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-5 pt-4 pb-3 pr-12">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M10 18a8 8 0 100-16 8 8 0 000 16z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
placeholder="Filter rows…"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted/40 border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{visibleRows.length === totalRows
|
||||
? `${totalRows} row${totalRows === 1 ? '' : 's'}`
|
||||
: `${visibleRows.length} of ${totalRows}`}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
title={
|
||||
copiedMd
|
||||
? 'Copied!'
|
||||
: visibleRows.length === totalRows
|
||||
? 'Copy as markdown'
|
||||
: `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as markdown`
|
||||
}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
copiedMd ? 'text-success' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{copiedMd ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyCsv}
|
||||
title={
|
||||
copiedCsv
|
||||
? 'Copied as CSV!'
|
||||
: visibleRows.length === totalRows
|
||||
? 'Copy as CSV'
|
||||
: `Copy ${visibleRows.length} row${visibleRows.length === 1 ? '' : 's'} as CSV`
|
||||
}
|
||||
className={`px-2 py-1 rounded-md text-[10px] font-bold tracking-tight uppercase leading-none transition-colors ${
|
||||
copiedCsv ? 'text-success' : 'text-primary hover:bg-primary/10'
|
||||
}`}
|
||||
>
|
||||
{copiedCsv ? '✓' : 'CSV'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto flex-1 px-5 pb-5">
|
||||
<table className="min-w-full border-collapse text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b border-border">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const sort = header.column.getIsSorted();
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30 sticky top-0 z-10 select-none cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
<SortIndicator dir={sort} />
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columnIds.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No rows match the filter.
|
||||
<div className="overflow-auto flex-1 px-5 pb-5">
|
||||
<table className="min-w-full border-collapse text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b border-border">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const sort = header.column.getIsSorted();
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
className="px-3 py-2 text-left font-semibold text-foreground/90 bg-muted/30 sticky top-0 z-10 select-none cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
<SortIndicator dir={sort} />
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columnIds.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No rows match the filter.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
visibleRows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-border/50 hover:bg-muted/20">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2 text-foreground/80">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
visibleRows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-border/50 hover:bg-muted/20">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2 text-foreground/80">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PopoutDialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -293,8 +256,6 @@ const TablePopoutImpl: React.FC<TablePopoutProps> = ({
|
||||
// re-render, which conflicts with web-highlighter's DOM mutations (the
|
||||
// library inserts <mark> tags into the live DOM) and React's reconciler
|
||||
// throws NotFoundError trying to remove nodes it doesn't own.
|
||||
// Callback identity is intentionally ignored — the behavior is stable even
|
||||
// if the parent hands us new function references.
|
||||
export const TablePopout = React.memo(
|
||||
TablePopoutImpl,
|
||||
(prev, next) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ export function renderProseBody(args: {
|
||||
imageBaseDir?: string;
|
||||
onImageClick?: (src: string, alt: string) => void;
|
||||
onOpenLinkedDoc?: (path: string) => void;
|
||||
onOpenCodeFile?: (path: string) => void;
|
||||
onNavigateAnchor?: (hash: string) => void;
|
||||
githubRepo?: string;
|
||||
}): React.ReactNode {
|
||||
@@ -26,6 +27,7 @@ export function renderProseBody(args: {
|
||||
imageBaseDir,
|
||||
onImageClick,
|
||||
onOpenLinkedDoc,
|
||||
onOpenCodeFile,
|
||||
onNavigateAnchor,
|
||||
githubRepo,
|
||||
} = args;
|
||||
@@ -36,6 +38,7 @@ export function renderProseBody(args: {
|
||||
onImageClick={onImageClick}
|
||||
text={text}
|
||||
onOpenLinkedDoc={onOpenLinkedDoc}
|
||||
onOpenCodeFile={onOpenCodeFile}
|
||||
onNavigateAnchor={onNavigateAnchor}
|
||||
githubRepo={githubRepo}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { Annotation, ImageAttachment } from '../types';
|
||||
import type { Annotation, CodeAnnotation, ImageAttachment } from '../types';
|
||||
import { fromShareable, parseShareableImages } from '../utils/sharing';
|
||||
import type { ShareableAnnotation } from '../utils/sharing';
|
||||
|
||||
@@ -18,6 +18,7 @@ const DEBOUNCE_MS = 500;
|
||||
/** New format: full objects. */
|
||||
interface DraftData {
|
||||
annotations: Annotation[];
|
||||
codeAnnotations?: CodeAnnotation[];
|
||||
globalAttachments: ImageAttachment[];
|
||||
ts: number;
|
||||
}
|
||||
@@ -47,6 +48,7 @@ function formatTimeAgo(ts: number): string {
|
||||
|
||||
interface UseAnnotationDraftOptions {
|
||||
annotations: Annotation[];
|
||||
codeAnnotations?: CodeAnnotation[];
|
||||
globalAttachments: ImageAttachment[];
|
||||
isApiMode: boolean;
|
||||
isSharedSession: boolean;
|
||||
@@ -55,19 +57,20 @@ interface UseAnnotationDraftOptions {
|
||||
|
||||
interface UseAnnotationDraftResult {
|
||||
draftBanner: { count: number; timeAgo: string } | null;
|
||||
restoreDraft: () => { annotations: Annotation[]; globalAttachments: ImageAttachment[] };
|
||||
restoreDraft: () => { annotations: Annotation[]; codeAnnotations: CodeAnnotation[]; globalAttachments: ImageAttachment[] };
|
||||
dismissDraft: () => void;
|
||||
}
|
||||
|
||||
export function useAnnotationDraft({
|
||||
annotations,
|
||||
codeAnnotations = [],
|
||||
globalAttachments,
|
||||
isApiMode,
|
||||
isSharedSession,
|
||||
submitted,
|
||||
}: UseAnnotationDraftOptions): UseAnnotationDraftResult {
|
||||
const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string } | null>(null);
|
||||
const draftDataRef = useRef<{ annotations: Annotation[]; globalAttachments: ImageAttachment[] } | null>(null);
|
||||
const draftDataRef = useRef<{ annotations: Annotation[]; codeAnnotations: CodeAnnotation[]; globalAttachments: ImageAttachment[] } | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasMountedRef = useRef(false);
|
||||
|
||||
@@ -87,25 +90,32 @@ export function useAnnotationDraft({
|
||||
}
|
||||
|
||||
let restoredAnnotations: Annotation[];
|
||||
let restoredCodeAnnotations: CodeAnnotation[] = [];
|
||||
let restoredGlobal: ImageAttachment[];
|
||||
|
||||
if (isLegacyDraft(data)) {
|
||||
// Old tuple format — deserialize via fromShareable
|
||||
restoredAnnotations = data.a.length > 0 ? fromShareable(data.a, data.d) : [];
|
||||
restoredGlobal = data.g ? (parseShareableImages(data.g as Parameters<typeof parseShareableImages>[0]) ?? []) : [];
|
||||
} else if (Array.isArray(data.annotations) && data.annotations.length > 0) {
|
||||
} else if (Array.isArray(data.annotations)) {
|
||||
// New direct-object format
|
||||
restoredAnnotations = data.annotations;
|
||||
restoredCodeAnnotations = Array.isArray(data.codeAnnotations) ? data.codeAnnotations : [];
|
||||
restoredGlobal = Array.isArray(data.globalAttachments) ? data.globalAttachments : [];
|
||||
} else if (Array.isArray((data as DraftData).codeAnnotations) && (data as DraftData).codeAnnotations!.length > 0) {
|
||||
restoredAnnotations = [];
|
||||
restoredCodeAnnotations = (data as DraftData).codeAnnotations!;
|
||||
restoredGlobal = Array.isArray((data as DraftData).globalAttachments) ? (data as DraftData).globalAttachments : [];
|
||||
} else {
|
||||
hasMountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredAnnotations.length > 0) {
|
||||
draftDataRef.current = { annotations: restoredAnnotations, globalAttachments: restoredGlobal };
|
||||
const totalCount = restoredAnnotations.length + restoredCodeAnnotations.length + restoredGlobal.length;
|
||||
if (totalCount > 0) {
|
||||
draftDataRef.current = { annotations: restoredAnnotations, codeAnnotations: restoredCodeAnnotations, globalAttachments: restoredGlobal };
|
||||
setDraftBanner({
|
||||
count: restoredAnnotations.length,
|
||||
count: totalCount,
|
||||
timeAgo: formatTimeAgo(data.ts || 0),
|
||||
});
|
||||
}
|
||||
@@ -120,13 +130,14 @@ export function useAnnotationDraft({
|
||||
useEffect(() => {
|
||||
if (!isApiMode || isSharedSession || submitted) return;
|
||||
if (!hasMountedRef.current) return;
|
||||
if (annotations.length === 0) return;
|
||||
if (annotations.length === 0 && codeAnnotations.length === 0 && globalAttachments.length === 0) return;
|
||||
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
const payload: DraftData = {
|
||||
annotations,
|
||||
codeAnnotations,
|
||||
globalAttachments,
|
||||
ts: Date.now(),
|
||||
};
|
||||
@@ -143,14 +154,14 @@ export function useAnnotationDraft({
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [annotations, globalAttachments, isApiMode, isSharedSession, submitted]);
|
||||
}, [annotations, codeAnnotations, globalAttachments, isApiMode, isSharedSession, submitted]);
|
||||
|
||||
const restoreDraft = useCallback(() => {
|
||||
const data = draftDataRef.current;
|
||||
setDraftBanner(null);
|
||||
draftDataRef.current = null;
|
||||
|
||||
if (!data) return { annotations: [], globalAttachments: [] };
|
||||
if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [] };
|
||||
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface CodeFileState {
|
||||
filepath: string;
|
||||
contents: string;
|
||||
prerenderedHTML?: string;
|
||||
}
|
||||
|
||||
interface UseCodeFilePopoutOptions {
|
||||
buildUrl: (codePath: string) => string;
|
||||
}
|
||||
|
||||
export interface UseCodeFilePopoutReturn {
|
||||
open: (codePath: string) => void;
|
||||
close: () => void;
|
||||
isLoading: boolean;
|
||||
popoutProps: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
filepath: string;
|
||||
contents: string;
|
||||
prerenderedHTML?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function useCodeFilePopout(
|
||||
options: UseCodeFilePopoutOptions
|
||||
): UseCodeFilePopoutReturn {
|
||||
const { buildUrl } = options;
|
||||
const [state, setState] = useState<CodeFileState | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setState(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(
|
||||
async (codePath: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch(buildUrl(codePath));
|
||||
const data = (await res.json()) as {
|
||||
codeFile?: boolean;
|
||||
contents?: string;
|
||||
filepath?: string;
|
||||
prerenderedHTML?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok || data.error || !data.codeFile || typeof data.contents !== 'string' || !data.filepath) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setState({ filepath: data.filepath, contents: data.contents, prerenderedHTML: data.prerenderedHTML });
|
||||
setIsLoading(false);
|
||||
} catch {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[buildUrl]
|
||||
);
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
isLoading,
|
||||
popoutProps: state
|
||||
? { open: true, onClose: close, filepath: state.filepath, contents: state.contents, prerenderedHTML: state.prerenderedHTML }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"./theme": "./theme.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "^1.1.12",
|
||||
"@plannotator/shared": "workspace:*",
|
||||
"@plannotator/web-highlighter": "^0.8.1",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface CodeAnnotation {
|
||||
lineEnd: number;
|
||||
side: 'old' | 'new'; // Maps to 'deletions' | 'additions' in @pierre/diffs
|
||||
text?: string;
|
||||
images?: ImageAttachment[];
|
||||
suggestedCode?: string;
|
||||
originalCode?: string; // Original selected lines for suggestion diff
|
||||
charStart?: number; // Character offset within lineStart (token-level selection)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function generateId(prefix?: string): string {
|
||||
const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
|
||||
return prefix ? `${prefix}-${id}` : id;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Block, type Annotation, type EditorAnnotation, type ImageAttachment } from '../types';
|
||||
import { Block, type Annotation, type CodeAnnotation, type EditorAnnotation, type ImageAttachment } from '../types';
|
||||
import { planDenyFeedback } from '@plannotator/shared/feedback-templates';
|
||||
|
||||
/**
|
||||
@@ -689,3 +689,37 @@ export const exportEditorAnnotations = (editorAnnotations: EditorAnnotation[]):
|
||||
return output;
|
||||
};
|
||||
|
||||
export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string => {
|
||||
if (annotations.length === 0) return '';
|
||||
|
||||
let output = `\n# Code File Feedback\n\nThe following feedback is on code files referenced from the reviewed document.\n\n`;
|
||||
const sorted = [...annotations].sort((a, b) => {
|
||||
if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath);
|
||||
if (a.lineStart !== b.lineStart) return a.lineStart - b.lineStart;
|
||||
return a.createdAt - b.createdAt;
|
||||
});
|
||||
|
||||
sorted.forEach((ann, index) => {
|
||||
const lineRange = ann.lineStart === ann.lineEnd
|
||||
? `line ${ann.lineStart}`
|
||||
: `lines ${ann.lineStart}-${ann.lineEnd}`;
|
||||
|
||||
output += `## ${index + 1}. ${ann.filePath} (${lineRange})\n`;
|
||||
if (ann.originalCode) {
|
||||
output += `\`\`\`\n${ann.originalCode}\n\`\`\`\n`;
|
||||
}
|
||||
if (ann.text) {
|
||||
output += `> ${ann.text}\n`;
|
||||
}
|
||||
if (ann.images && ann.images.length > 0) {
|
||||
output += `**Attached images:**\n`;
|
||||
ann.images.forEach((img) => {
|
||||
output += `- [${img.name}] \`${img.path}\`\n`;
|
||||
});
|
||||
}
|
||||
output += '\n';
|
||||
});
|
||||
|
||||
output += `---\n`;
|
||||
return output;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user