diff --git a/chrome/src/host/background.ts b/chrome/src/host/background.ts index 298e189c..67c79b18 100644 --- a/chrome/src/host/background.ts +++ b/chrome/src/host/background.ts @@ -1151,12 +1151,17 @@ async function getMenuTitle(): Promise { // Initialize context menu for viewing any file as markdown async function initializeContextMenu(): Promise { try { - // Remove old menu item if exists (migration from preview to view) + // Remove old menu items if exist (prevents duplicate ID error on SW restart) try { await chrome.contextMenus.remove('preview-as-markdown'); } catch { // Ignore if old menu doesn't exist } + try { + await chrome.contextMenus.remove('view-as-markdown'); + } catch { + // Ignore if menu doesn't exist yet + } const title = await getMenuTitle(); chrome.contextMenus.create({ diff --git a/chrome/src/webview/ui/toolbar.ts b/chrome/src/webview/ui/toolbar.ts index 2582fcda..7f9c3bcf 100644 --- a/chrome/src/webview/ui/toolbar.ts +++ b/chrome/src/webview/ui/toolbar.ts @@ -17,6 +17,8 @@ import type { ToolbarManagerInstance, GenerateToolbarHTMLOptions } from '../../../../src/types/index'; +import { createRemarkMode } from '../../../../src/ui/remark-mode'; +import type { RemarkModeController } from '../../../../src/ui/remark-mode'; // SVG icons for different layouts export const layoutIcons: Record = { @@ -57,6 +59,9 @@ export function createToolbarManager(options: ToolbarManagerOptions): ToolbarMan onToggleSourceMode, getSourceMode, isSourceModeActive, + enableRemarkMode, + getRemarkContainer, + getRemarkRawMarkdown, } = options; // Layout configurations @@ -75,6 +80,33 @@ export function createToolbarManager(options: ToolbarManagerOptions): ToolbarMan // Global zoom state let currentZoomLevel = 100; + // Remark Mode controller + let remarkController: RemarkModeController | null = null; + if (enableRemarkMode && getRemarkContainer) { + remarkController = createRemarkMode({ + getContainer: getRemarkContainer, + getRawMarkdown: getRemarkRawMarkdown || (() => rawMarkdown), + onModeChange: (isActive: boolean) => { + const btn = document.getElementById('toggle-remark-btn'); + if (!btn) return; + btn.classList.toggle('remark-active', isActive); + const title = isActive + ? (chrome.i18n?.getMessage('remark_exit_mode') || 'Exit Remark Mode') + : (chrome.i18n?.getMessage('remark_mode') || 'Remark Mode'); + btn.title = title; + btn.setAttribute('aria-label', title); + btn.setAttribute('aria-pressed', String(isActive)); + }, + onAnnotationCountChange: (count: number) => { + const badge = document.getElementById('remark-count-badge'); + if (badge) { + badge.textContent = count > 0 ? String(count) : ''; + badge.style.display = count > 0 ? 'flex' : 'none'; + } + }, + }); + } + async function exportDocxFromToolbar(): Promise { const downloadBtn = document.getElementById('download-btn') as HTMLButtonElement | null; if (!downloadBtn || downloadBtn.disabled) { @@ -374,6 +406,40 @@ export function createToolbarManager(options: ToolbarManagerOptions): ToolbarMan sourceToggleBtn.addEventListener('click', () => { onToggleSourceMode(); updateSourceToggleUI(); + // Exit remark mode when entering source mode + if (getSourceMode() && remarkController?.isActive()) { + remarkController.exit(); + updateRemarkToggleUI(); + } + }); + } + + // Remark Mode toggle button + const remarkToggleBtn = document.getElementById('toggle-remark-btn'); + function updateRemarkToggleUI(): void { + if (!remarkToggleBtn) return; + const isActive = remarkController?.isActive() ?? false; + remarkToggleBtn.classList.toggle('remark-active', isActive); + remarkToggleBtn.title = isActive + ? (chrome.i18n?.getMessage('remark_exit_mode') || 'Exit Remark Mode') + : (chrome.i18n?.getMessage('remark_mode') || 'Remark Mode'); + remarkToggleBtn.setAttribute('aria-label', remarkToggleBtn.title); + remarkToggleBtn.setAttribute('aria-pressed', String(isActive)); + } + + if (remarkToggleBtn && remarkController) { + // Load persisted annotations on init + void remarkController.loadAnnotations(); + updateRemarkToggleUI(); + remarkToggleBtn.addEventListener('click', () => { + if (remarkController!.isActive()) { + remarkController!.exit(); + } else { + // Don't enter remark mode while in source mode + if (getSourceMode?.()) return; + remarkController!.enter(); + } + updateRemarkToggleUI(); }); } @@ -500,6 +566,7 @@ export function generateToolbarHTML(options: GenerateToolbarHTMLOptions): string initialMaxWidth, initialZoom, enableSourceToggle, + enableRemarkMode, } = options; const toolbarLayoutTitleNormal = translate('toolbar_layout_title_normal'); @@ -564,6 +631,15 @@ export function generateToolbarHTML(options: GenerateToolbarHTMLOptions): string ` : ''}
+ ${enableRemarkMode ? ` +
+ + +
` : ''}
+
`; } diff --git a/chrome/src/webview/viewer-main.ts b/chrome/src/webview/viewer-main.ts index a3ede4a7..c11f98af 100644 --- a/chrome/src/webview/viewer-main.ts +++ b/chrome/src/webview/viewer-main.ts @@ -725,6 +725,9 @@ export async function initializeViewerMain(options: ViewerMainOptions): Promise< }, getSourceMode: () => sourceModeEnabled, isSourceModeActive: () => (isMarkdownSourceToggleEnabled() && sourceModeEnabled) || renderState.codeView, + enableRemarkMode: true, + getRemarkContainer: () => document.getElementById('markdown-content'), + getRemarkRawMarkdown: () => liveRawContent, }); toolbarManager.setInitialZoom(initialZoom); @@ -737,6 +740,7 @@ export async function initializeViewerMain(options: ViewerMainOptions): Promise< initialMaxWidth, initialZoom, enableSourceToggle: isMarkdownSourceToggleEnabled(), + enableRemarkMode: true, }); if (!initialTocVisible) { document.body.classList.add('toc-hidden'); diff --git a/src/_locales/de/messages.json b/src/_locales/de/messages.json index 863a8af8..95401ea7 100644 --- a/src/_locales/de/messages.json +++ b/src/_locales/de/messages.json @@ -347,6 +347,70 @@ "message": "Zuletzt geöffnet", "description": "Title for recent files section" }, + "remark_add_note": { + "message": "Notiz hinzufügen…", + "description": "Placeholder for the note input in the annotation popup and sidebar" + }, + "remark_cancel": { + "message": "Abbrechen", + "description": "Cancel button in the annotation popup" + }, + "remark_color_blue": { + "message": "Frage", + "description": "Label for the blue annotation color" + }, + "remark_color_green": { + "message": "Behalten", + "description": "Label for the green annotation color" + }, + "remark_color_pink": { + "message": "Bedenken", + "description": "Label for the pink annotation color" + }, + "remark_color_yellow": { + "message": "Vorschlag", + "description": "Label for the yellow annotation color" + }, + "remark_copied": { + "message": "Kopiert!", + "description": "Feedback shown after remarks are successfully copied" + }, + "remark_copy_btn": { + "message": "Anmerkungen kopieren", + "description": "Button label to copy all remarks to clipboard and exit Remark Mode" + }, + "remark_copy_failed": { + "message": "Fehler", + "description": "Feedback shown when copying remarks fails" + }, + "remark_copy_tooltip": { + "message": "Alle Anmerkungen in die Zwischenablage kopieren und den Anmerkungsmodus beenden", + "description": "Tooltip for the Copy remarks button" + }, + "remark_edit_note": { + "message": "Zum Bearbeiten klicken", + "description": "Tooltip on a remark note in the sidebar indicating it is editable" + }, + "remark_empty": { + "message": "Text auswählen, um Anmerkungen hinzuzufügen", + "description": "Placeholder shown in sidebar when no remarks exist" + }, + "remark_exit_mode": { + "message": "Anmerkungsmodus beenden", + "description": "Toolbar button tooltip to exit Remark Mode" + }, + "remark_mode": { + "message": "Anmerkungsmodus", + "description": "Toolbar button tooltip to enter Remark Mode" + }, + "remark_save": { + "message": "Speichern", + "description": "Save button in the annotation popup" + }, + "remark_sidebar_title": { + "message": "Anmerkungen", + "description": "Title of the Remark Mode sidebar panel" + }, "remove_from_list": { "message": "Aus Liste entfernen", "description": "Menu item to remove file from recent list" diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 39f64567..9d2c242d 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -347,6 +347,70 @@ "message": "Recent Files", "description": "Title for recent files section" }, + "remark_add_note": { + "message": "Add a note…", + "description": "Placeholder for the note input in the annotation popup and sidebar" + }, + "remark_cancel": { + "message": "Cancel", + "description": "Cancel button in the annotation popup" + }, + "remark_color_blue": { + "message": "Question", + "description": "Label for the blue annotation color" + }, + "remark_color_green": { + "message": "Keep", + "description": "Label for the green annotation color" + }, + "remark_color_pink": { + "message": "Concern", + "description": "Label for the pink annotation color" + }, + "remark_color_yellow": { + "message": "Suggestion", + "description": "Label for the yellow annotation color" + }, + "remark_copied": { + "message": "Copied!", + "description": "Feedback shown after remarks are successfully copied" + }, + "remark_copy_btn": { + "message": "Copy remarks", + "description": "Button label to copy all remarks to clipboard and exit Remark Mode" + }, + "remark_copy_failed": { + "message": "Failed", + "description": "Feedback shown when copying remarks fails" + }, + "remark_copy_tooltip": { + "message": "Copy all remarks to clipboard, then exit Remark Mode", + "description": "Tooltip for the Copy remarks button" + }, + "remark_edit_note": { + "message": "Click to edit", + "description": "Tooltip on a remark note in the sidebar indicating it is editable" + }, + "remark_empty": { + "message": "Select text to add remarks", + "description": "Placeholder shown in sidebar when no remarks exist" + }, + "remark_exit_mode": { + "message": "Exit Remark Mode", + "description": "Toolbar button tooltip to exit Remark Mode" + }, + "remark_mode": { + "message": "Remark Mode", + "description": "Toolbar button tooltip to enter Remark Mode" + }, + "remark_save": { + "message": "Save", + "description": "Save button in the annotation popup" + }, + "remark_sidebar_title": { + "message": "Remarks", + "description": "Title of the Remark Mode sidebar panel" + }, "remove_from_list": { "message": "Remove from list", "description": "Menu item to remove file from recent list" diff --git a/src/_locales/es/messages.json b/src/_locales/es/messages.json index d09cefdf..c549a1da 100644 --- a/src/_locales/es/messages.json +++ b/src/_locales/es/messages.json @@ -347,6 +347,70 @@ "message": "Archivos recientes", "description": "Title for recent files section" }, + "remark_add_note": { + "message": "Añadir nota…", + "description": "Placeholder for the note input in the annotation popup and sidebar" + }, + "remark_cancel": { + "message": "Cancelar", + "description": "Cancel button in the annotation popup" + }, + "remark_color_blue": { + "message": "Pregunta", + "description": "Label for the blue annotation color" + }, + "remark_color_green": { + "message": "Mantener", + "description": "Label for the green annotation color" + }, + "remark_color_pink": { + "message": "Preocupación", + "description": "Label for the pink annotation color" + }, + "remark_color_yellow": { + "message": "Sugerencia", + "description": "Label for the yellow annotation color" + }, + "remark_copied": { + "message": "¡Copiado!", + "description": "Feedback shown after remarks are successfully copied" + }, + "remark_copy_btn": { + "message": "Copiar anotaciones", + "description": "Button label to copy all remarks to clipboard and exit Remark Mode" + }, + "remark_copy_failed": { + "message": "Error", + "description": "Feedback shown when copying remarks fails" + }, + "remark_copy_tooltip": { + "message": "Copiar todas las anotaciones al portapapeles y salir del modo anotación", + "description": "Tooltip for the Copy remarks button" + }, + "remark_edit_note": { + "message": "Haz clic para editar", + "description": "Tooltip on a remark note in the sidebar indicating it is editable" + }, + "remark_empty": { + "message": "Selecciona texto para añadir anotaciones", + "description": "Placeholder shown in sidebar when no remarks exist" + }, + "remark_exit_mode": { + "message": "Salir del modo anotación", + "description": "Toolbar button tooltip to exit Remark Mode" + }, + "remark_mode": { + "message": "Modo anotación", + "description": "Toolbar button tooltip to enter Remark Mode" + }, + "remark_save": { + "message": "Guardar", + "description": "Save button in the annotation popup" + }, + "remark_sidebar_title": { + "message": "Anotaciones", + "description": "Title of the Remark Mode sidebar panel" + }, "remove_from_list": { "message": "Eliminar de la lista", "description": "Menu item to remove file from recent list" diff --git a/src/_locales/fr/messages.json b/src/_locales/fr/messages.json index 6c8c8d74..b21cb2f8 100644 --- a/src/_locales/fr/messages.json +++ b/src/_locales/fr/messages.json @@ -347,6 +347,70 @@ "message": "Fichiers récents", "description": "Title for recent files section" }, + "remark_add_note": { + "message": "Ajouter une note…", + "description": "Placeholder for the note input in the annotation popup and sidebar" + }, + "remark_cancel": { + "message": "Annuler", + "description": "Cancel button in the annotation popup" + }, + "remark_color_blue": { + "message": "Interrogation", + "description": "Label for the blue annotation color" + }, + "remark_color_green": { + "message": "Conserver", + "description": "Label for the green annotation color" + }, + "remark_color_pink": { + "message": "Préoccupation", + "description": "Label for the pink annotation color" + }, + "remark_color_yellow": { + "message": "Recommandation", + "description": "Label for the yellow annotation color" + }, + "remark_copied": { + "message": "Copié !", + "description": "Feedback shown after remarks are successfully copied" + }, + "remark_copy_btn": { + "message": "Copier les annotations", + "description": "Button label to copy all remarks to clipboard and exit Remark Mode" + }, + "remark_copy_failed": { + "message": "Échec", + "description": "Feedback shown when copying remarks fails" + }, + "remark_copy_tooltip": { + "message": "Copier toutes les annotations dans le presse-papiers et quitter le mode annotation", + "description": "Tooltip for the Copy remarks button" + }, + "remark_edit_note": { + "message": "Cliquer pour modifier", + "description": "Tooltip on a remark note in the sidebar indicating it is editable" + }, + "remark_empty": { + "message": "Sélectionnez du texte pour ajouter des annotations", + "description": "Placeholder shown in sidebar when no remarks exist" + }, + "remark_exit_mode": { + "message": "Quitter le mode annotation", + "description": "Toolbar button tooltip to exit Remark Mode" + }, + "remark_mode": { + "message": "Mode annotation", + "description": "Toolbar button tooltip to enter Remark Mode" + }, + "remark_save": { + "message": "Enregistrer", + "description": "Save button in the annotation popup" + }, + "remark_sidebar_title": { + "message": "Annotations", + "description": "Title of the Remark Mode sidebar panel" + }, "remove_from_list": { "message": "Retirer de la liste", "description": "Menu item to remove file from recent list" diff --git a/src/_locales/ja/messages.json b/src/_locales/ja/messages.json index 1ce10cb9..b62cdb0e 100644 --- a/src/_locales/ja/messages.json +++ b/src/_locales/ja/messages.json @@ -347,6 +347,70 @@ "message": "最近のファイル", "description": "Title for recent files section" }, + "remark_add_note": { + "message": "メモを追加…", + "description": "Placeholder for the note input in the annotation popup and sidebar" + }, + "remark_cancel": { + "message": "キャンセル", + "description": "Cancel button in the annotation popup" + }, + "remark_color_blue": { + "message": "質問", + "description": "Label for the blue annotation color" + }, + "remark_color_green": { + "message": "維持", + "description": "Label for the green annotation color" + }, + "remark_color_pink": { + "message": "懸念", + "description": "Label for the pink annotation color" + }, + "remark_color_yellow": { + "message": "提案", + "description": "Label for the yellow annotation color" + }, + "remark_copied": { + "message": "コピーしました!", + "description": "Feedback shown after remarks are successfully copied" + }, + "remark_copy_btn": { + "message": "コメントをコピー", + "description": "Button label to copy all remarks to clipboard and exit Remark Mode" + }, + "remark_copy_failed": { + "message": "コピー失敗", + "description": "Feedback shown when copying remarks fails" + }, + "remark_copy_tooltip": { + "message": "すべてのコメントをクリップボードにコピーし、コメントモードを終了します", + "description": "Tooltip for the Copy remarks button" + }, + "remark_edit_note": { + "message": "クリックして編集", + "description": "Tooltip on a remark note in the sidebar indicating it is editable" + }, + "remark_empty": { + "message": "テキストを選択してコメントを追加", + "description": "Placeholder shown in sidebar when no remarks exist" + }, + "remark_exit_mode": { + "message": "コメントモードを終了", + "description": "Toolbar button tooltip to exit Remark Mode" + }, + "remark_mode": { + "message": "コメントモード", + "description": "Toolbar button tooltip to enter Remark Mode" + }, + "remark_save": { + "message": "保存する", + "description": "Save button in the annotation popup" + }, + "remark_sidebar_title": { + "message": "コメント", + "description": "Title of the Remark Mode sidebar panel" + }, "remove_from_list": { "message": "リストから削除", "description": "Menu item to remove file from recent list" diff --git a/src/_locales/ko/messages.json b/src/_locales/ko/messages.json index 24395367..87192ffa 100644 --- a/src/_locales/ko/messages.json +++ b/src/_locales/ko/messages.json @@ -347,6 +347,70 @@ "message": "최근 파일", "description": "Title for recent files section" }, + "remark_add_note": { + "message": "메모 추가…", + "description": "Placeholder for the note input in the annotation popup and sidebar" + }, + "remark_cancel": { + "message": "취소", + "description": "Cancel button in the annotation popup" + }, + "remark_color_blue": { + "message": "질문", + "description": "Label for the blue annotation color" + }, + "remark_color_green": { + "message": "유지", + "description": "Label for the green annotation color" + }, + "remark_color_pink": { + "message": "우려", + "description": "Label for the pink annotation color" + }, + "remark_color_yellow": { + "message": "제안", + "description": "Label for the yellow annotation color" + }, + "remark_copied": { + "message": "복사됨!", + "description": "Feedback shown after remarks are successfully copied" + }, + "remark_copy_btn": { + "message": "주석 복사", + "description": "Button label to copy all remarks to clipboard and exit Remark Mode" + }, + "remark_copy_failed": { + "message": "복사 실패", + "description": "Feedback shown when copying remarks fails" + }, + "remark_copy_tooltip": { + "message": "모든 주석을 클립보드에 복사하고 주석 모드를 종료합니다", + "description": "Tooltip for the Copy remarks button" + }, + "remark_edit_note": { + "message": "클릭하여 편집", + "description": "Tooltip on a remark note in the sidebar indicating it is editable" + }, + "remark_empty": { + "message": "텍스트를 선택하여 주석 추가", + "description": "Placeholder shown in sidebar when no remarks exist" + }, + "remark_exit_mode": { + "message": "주석 모드 종료", + "description": "Toolbar button tooltip to exit Remark Mode" + }, + "remark_mode": { + "message": "주석 모드", + "description": "Toolbar button tooltip to enter Remark Mode" + }, + "remark_save": { + "message": "저장", + "description": "Save button in the annotation popup" + }, + "remark_sidebar_title": { + "message": "주석", + "description": "Title of the Remark Mode sidebar panel" + }, "remove_from_list": { "message": "목록에서 제거", "description": "Menu item to remove file from recent list" diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json index 7531c2c2..07eaefff 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -347,6 +347,70 @@ "message": "最近文件", "description": "Title for recent files section" }, + "remark_add_note": { + "message": "添加备注…", + "description": "Placeholder for the note input in the annotation popup and sidebar" + }, + "remark_cancel": { + "message": "取消", + "description": "Cancel button in the annotation popup" + }, + "remark_color_blue": { + "message": "疑问", + "description": "Label for the blue annotation color" + }, + "remark_color_green": { + "message": "保留", + "description": "Label for the green annotation color" + }, + "remark_color_pink": { + "message": "关注", + "description": "Label for the pink annotation color" + }, + "remark_color_yellow": { + "message": "建议", + "description": "Label for the yellow annotation color" + }, + "remark_copied": { + "message": "已复制!", + "description": "Feedback shown after remarks are successfully copied" + }, + "remark_copy_btn": { + "message": "复制批注", + "description": "Button label to copy all remarks to clipboard and exit Remark Mode" + }, + "remark_copy_failed": { + "message": "复制失败", + "description": "Feedback shown when copying remarks fails" + }, + "remark_copy_tooltip": { + "message": "复制所有批注到剪贴板,然后退出批注模式", + "description": "Tooltip for the Copy remarks button" + }, + "remark_edit_note": { + "message": "点击编辑", + "description": "Tooltip on a remark note in the sidebar indicating it is editable" + }, + "remark_empty": { + "message": "选中文字以添加批注", + "description": "Placeholder shown in sidebar when no remarks exist" + }, + "remark_exit_mode": { + "message": "退出批注模式", + "description": "Toolbar button tooltip to exit Remark Mode" + }, + "remark_mode": { + "message": "批注模式", + "description": "Toolbar button tooltip to enter Remark Mode" + }, + "remark_save": { + "message": "保存", + "description": "Save button in the annotation popup" + }, + "remark_sidebar_title": { + "message": "批注", + "description": "Title of the Remark Mode sidebar panel" + }, "remove_from_list": { "message": "从列表移除", "description": "Menu item to remove file from recent list" diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json index bf563e6f..7c53b222 100644 --- a/src/_locales/zh_TW/messages.json +++ b/src/_locales/zh_TW/messages.json @@ -347,6 +347,70 @@ "message": "最近檔案", "description": "Title for recent files section" }, + "remark_add_note": { + "message": "新增備注…", + "description": "Placeholder for the note input in the annotation popup and sidebar" + }, + "remark_cancel": { + "message": "取消", + "description": "Cancel button in the annotation popup" + }, + "remark_color_blue": { + "message": "疑問", + "description": "Label for the blue annotation color" + }, + "remark_color_green": { + "message": "保留", + "description": "Label for the green annotation color" + }, + "remark_color_pink": { + "message": "關注", + "description": "Label for the pink annotation color" + }, + "remark_color_yellow": { + "message": "建議", + "description": "Label for the yellow annotation color" + }, + "remark_copied": { + "message": "已複製!", + "description": "Feedback shown after remarks are successfully copied" + }, + "remark_copy_btn": { + "message": "複製批注", + "description": "Button label to copy all remarks to clipboard and exit Remark Mode" + }, + "remark_copy_failed": { + "message": "複製失敗", + "description": "Feedback shown when copying remarks fails" + }, + "remark_copy_tooltip": { + "message": "複製所有批注到剪貼板,然後退出批注模式", + "description": "Tooltip for the Copy remarks button" + }, + "remark_edit_note": { + "message": "點擊編輯", + "description": "Tooltip on a remark note in the sidebar indicating it is editable" + }, + "remark_empty": { + "message": "選取文字以新增批注", + "description": "Placeholder shown in sidebar when no remarks exist" + }, + "remark_exit_mode": { + "message": "退出批注模式", + "description": "Toolbar button tooltip to exit Remark Mode" + }, + "remark_mode": { + "message": "批注模式", + "description": "Toolbar button tooltip to enter Remark Mode" + }, + "remark_save": { + "message": "儲存", + "description": "Save button in the annotation popup" + }, + "remark_sidebar_title": { + "message": "批注", + "description": "Title of the Remark Mode sidebar panel" + }, "remove_from_list": { "message": "從列表移除", "description": "Menu item to remove file from recent list" diff --git a/src/types/toolbar.ts b/src/types/toolbar.ts index cdb67c7a..742f063e 100644 --- a/src/types/toolbar.ts +++ b/src/types/toolbar.ts @@ -49,6 +49,12 @@ export interface ToolbarManagerOptions { getSourceMode?: () => boolean; /** Whether current view should save raw file on Ctrl/Cmd+S */ isSourceModeActive?: () => boolean; + /** Whether to show remark mode toggle button */ + enableRemarkMode?: boolean; + /** Get the container for remark annotations (rendered markdown div) */ + getRemarkContainer?: () => HTMLElement | null; + /** Get raw markdown for remark export */ + getRemarkRawMarkdown?: () => string; } /** @@ -61,6 +67,7 @@ export interface GenerateToolbarHTMLOptions { initialMaxWidth: string; initialZoom: number; enableSourceToggle?: boolean; + enableRemarkMode?: boolean; } /** diff --git a/src/ui/remark-mode.ts b/src/ui/remark-mode.ts new file mode 100644 index 00000000..9df1cd80 --- /dev/null +++ b/src/ui/remark-mode.ts @@ -0,0 +1,1414 @@ +import { getCurrentDocumentUrl } from '../core/document-utils'; +import { escapeHtml } from '../core/markdown-processor'; +import { + truncate, formatLineRef, getBlockRange, rangesOverlap, isMediaBlock, + formatExportText, + COLOR_MAP, COLOR_LABELS, SKIP_ANNOTATION_TAGS, + type RemarkColor, type RemarkAnnotation, +} from './remark-utils'; + +/** + * Remark Mode — Block-level annotation for rendered Markdown + * + * Features: + * - Toolbar toggle to enter/exit Remark Mode + * - Text selection → popup with color picker and note input + * - Block-level highlights using data-line attributes + * - Right sidebar listing all annotations with delete + * - Hover tooltip showing annotation note on highlighted blocks + * - Persistence via chrome.storage.local (keyed by page URL) + * - Clipboard export in structured prompt format + */ + +// ─── Types (re-exported from remark-utils for consumers) ───────────────────── + +export type { RemarkColor, RemarkAnnotation } from './remark-utils'; + +export interface RemarkModeController { + isActive(): boolean; + enter(): void; + exit(): void; + getAnnotations(): RemarkAnnotation[]; + removeAnnotation(id: string): void; + updateAnnotationNote(id: string, note: string): void; + exportToClipboard(): Promise<{ ok: boolean; reason?: string }>; + loadAnnotations(): Promise; + dispose(): void; +} + +export interface RemarkModeOptions { + /** The container holding rendered markdown blocks with data-line attrs */ + getContainer(): HTMLElement | null; + /** Get raw markdown source for export context */ + getRawMarkdown(): string; + /** Callback when mode changes */ + onModeChange?(active: boolean): void; + /** Callback when annotation count changes (for badge on toolbar button) */ + onAnnotationCountChange?(count: number): void; + /** Storage key for persistence (typically the page URL) */ + getStorageKey?(): string; +} + +// ─── i18n helper ───────────────────────────────────────────────────────────── + +function t(key: string, fallback: string): string { + try { + if (typeof chrome !== 'undefined' && chrome.i18n?.getMessage) { + const msg = chrome.i18n.getMessage(key); + if (msg) return msg; + } + } catch { + // Non-extension context + } + return fallback; +} + +// ─── Controller ────────────────────────────────────────────────────────────── + +export function createRemarkMode(options: RemarkModeOptions): RemarkModeController { + const { getContainer, getRawMarkdown, onModeChange, onAnnotationCountChange, getStorageKey } = options; + + let active = false; + let annotations: RemarkAnnotation[] = []; + let abortController: AbortController | null = null; + let popupEl: HTMLElement | null = null; + let sidebarEl: HTMLElement | null = null; + let tooltipEl: HTMLElement | null = null; + let pendingFocusId: string | null = null; // for focus chain across re-renders + + function isActive(): boolean { + return active; + } + + function enter(): void { + if (active) return; + active = true; + abortController = new AbortController(); + const signal = abortController.signal; + + const container = getContainer(); + if (container) { + container.classList.add('remark-mode-active'); + container.addEventListener('mouseup', handleSelection, { signal }); + container.addEventListener('mouseover', handleHover, { signal }); + container.addEventListener('mouseout', handleHoverOut, { signal }); + } + + + document.body.classList.add('remark-panel-open'); + injectStyles(); + renderHighlights(); + showSidebar(); + + // Schedule highlight render for when markdown DOM is ready. + // Handles: container not yet in DOM, [data-line] not yet rendered, or async re-render. + if (!container || !container.querySelector('[data-line]')) { + scheduleHighlightsAfterRender(); + } + + onModeChange?.(true); + } + + function exit(): void { + if (!active) return; + active = false; + abortController?.abort(); + abortController = null; + + hidePopup(); + hideTooltip(); + hideSidebar(); // hideSidebar handles removing remark-panel-open after transition + const container = getContainer(); + if (container) { + container.classList.remove('remark-mode-active'); + } + clearHighlights(); + onModeChange?.(false); + } + + // ─── Persistence ───────────────────────────────────────────────────────── + + function storageKey(): string { + if (getStorageKey) return getStorageKey(); + // Normalize against the active document rather than the iframe URL, so + // workspace mode keys by the current file path instead of viewer-embed.html. + try { + const url = new URL(getCurrentDocumentUrl()); + url.hash = ''; + url.search = ''; + return `rmk:${url.href}`; + } catch { + const { origin, pathname } = window.location; + return `rmk:${origin}${pathname}`; + } + } + + async function saveAnnotations(): Promise { + try { + const key = storageKey(); + if (typeof chrome !== 'undefined' && chrome.storage?.local) { + await chrome.storage.local.set({ [key]: annotations }); + } else { + localStorage.setItem(key, JSON.stringify(annotations)); + } + } catch { + // Silently fail — annotations remain in-memory + } + } + + async function loadAnnotations(): Promise { + try { + const key = storageKey(); + if (typeof chrome !== 'undefined' && chrome.storage?.local) { + const result = await chrome.storage.local.get(key); + if (result[key] && Array.isArray(result[key])) { + annotations = result[key]; + } + } else { + const stored = localStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) annotations = parsed; + } + } + } catch { + // Start with empty annotations + } + if (active) { + renderHighlights(); + renderSidebarContent(); + } else { + notifyCount(); + // If URL contains ?remarker=true, auto-enter after DOM is ready + if (typeof window !== 'undefined' && window.location.search.includes('remarker=true')) { + if (annotations.length > 0) { + enter(); + } else { + scheduleHighlightsAfterRender(); + } + } else if (annotations.length > 0) { + // Not auto-entering, but schedule highlights so badge renders after DOM is ready + scheduleHighlightsAfterRender(); + } + } + } + + function scheduleHighlightsAfterRender(): void { + function tryOnContainer(): void { + const container = getContainer(); + if (!container) return; + + if (container.querySelector('[data-line]')) { + if (active) { renderHighlights(); renderSidebarContent(); } else { notifyCount(); } + return; + } + + const obs = new MutationObserver(() => { + if (container.querySelector('[data-line]')) { + obs.disconnect(); + if (active) { + renderHighlights(); + renderSidebarContent(); + } else { + notifyCount(); + } + } + }); + obs.observe(container, { childList: true, subtree: true }); + } + + const container = getContainer(); + if (container) { + tryOnContainer(); + return; + } + + // Container not yet in DOM (e.g., ?remarker=true fires before markdown renders). + // Watch document.body until the container element appears. + const bodyObs = new MutationObserver(() => { + if (getContainer()) { + bodyObs.disconnect(); + tryOnContainer(); + } + }); + bodyObs.observe(document.body, { childList: true, subtree: true }); + } + + // ─── Selection Handling ────────────────────────────────────────────────── + + function handleSelection(): void { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || !sel.rangeCount) { + return; + } + + const range = sel.getRangeAt(0); + const container = getContainer(); + if (!container || !container.contains(range.commonAncestorContainer)) { + return; + } + + const selectedText = sel.toString().trim(); + if (!selectedText) return; + + const { startLine, endLine, blockId, startBlock } = findBlockRange(range, container); + if (startLine < 0) return; + + // Skip media blocks (images, charts, diagrams) + if (startBlock && isMediaBlock(startBlock)) return; + + showPopup(range, selectedText, startLine, endLine, blockId, startBlock ?? undefined); + } + + + function findBlockRange(range: Range, container: HTMLElement): { startLine: number; endLine: number; blockId?: string; startBlock: HTMLElement | null } { + const startBlock = findBlockAncestor(range.startContainer, container); + const endBlock = findBlockAncestor(range.endContainer, container); + + if (!startBlock) return { startLine: -1, endLine: -1, startBlock: null }; + + const startLine = Number(startBlock.getAttribute('data-line')) || 0; + const startCount = Number(startBlock.getAttribute('data-line-count')) || 1; + + if (!endBlock || endBlock === startBlock) { + return { + startLine, + endLine: startLine + startCount - 1, + blockId: startBlock.getAttribute('data-block-id') || undefined, + startBlock, + }; + } + + const endLine = Number(endBlock.getAttribute('data-line')) || 0; + const endCount = Number(endBlock.getAttribute('data-line-count')) || 1; + return { + startLine, + endLine: endLine + endCount - 1, + blockId: startBlock.getAttribute('data-block-id') || undefined, + startBlock, + }; + } + + function findBlockAncestor(node: Node, container: HTMLElement): HTMLElement | null { + let el: HTMLElement | null = node instanceof HTMLElement ? node : node.parentElement; + while (el && el !== container) { + if (el.hasAttribute('data-line') && el.hasAttribute('data-block-id')) return el; + el = el.parentElement; + } + return null; + } + + // ─── Hover Tooltip ───────────────────────────────────────────────────────── + + function handleHover(e: Event): void { + const target = (e.target as HTMLElement).closest?.('[data-line][data-block-id]') as HTMLElement | null; + if (!target || !target.classList.contains('remark-highlighted')) return; + if (isMediaBlock(target)) return; + + const { start: blockLine, end: blockEnd } = getBlockRange(target); + + // Find annotations for this block + const blockAnns = annotations.filter(a => rangesOverlap(a.startLine, a.endLine, blockLine, blockEnd)); + if (blockAnns.length === 0) return; + + showTooltip(target, blockAnns); + } + + function handleHoverOut(e: Event): void { + const target = (e.target as HTMLElement).closest?.('[data-line][data-block-id]') as HTMLElement | null; + if (!target) return; + // Only hide if moving away from a highlighted block + const related = (e as MouseEvent).relatedTarget as HTMLElement | null; + if (related && (related.closest?.('.remark-tooltip') || related.closest?.('[data-line][data-block-id].remark-highlighted'))) { + return; + } + hideTooltip(); + } + + function showTooltip(anchor: HTMLElement, anns: RemarkAnnotation[]): void { + hideTooltip(); + tooltipEl = document.createElement('div'); + tooltipEl.className = 'remark-tooltip'; + + const items = anns.map(a => { + const noteText = a.note + ? escapeHtml(a.note) + : `${escapeHtml(truncate(a.selectedText, 60))}`; + return `
${COLOR_MAP[a.color].emoji} ${noteText}
`; + }).join(''); + + tooltipEl.innerHTML = items; + document.body.appendChild(tooltipEl); + + const rect = anchor.getBoundingClientRect(); + tooltipEl.style.top = `${rect.top - tooltipEl.offsetHeight - 6}px`; + tooltipEl.style.left = `${rect.left}px`; + + // Keep in viewport + if (tooltipEl.offsetTop < 4) { + tooltipEl.style.top = `${rect.bottom + 6}px`; + } + + tooltipEl.addEventListener('mouseleave', () => hideTooltip()); + } + + function hideTooltip(): void { + if (tooltipEl) { + tooltipEl.remove(); + tooltipEl = null; + } + } + + // ─── Popup ───────────────────────────────────────────────────────────────── + + function showPopup(range: Range, selectedText: string, startLine: number, endLine: number, blockId?: string, targetBlock?: HTMLElement): void { + hidePopup(); + + // Highlight the block being annotated + if (targetBlock) { + targetBlock.classList.add('remark-popup-target'); + } + + // Create annotation immediately with default color + const annId = generateId(); + const ann: RemarkAnnotation = { + id: annId, startLine, endLine, selectedText, + note: '', color: 'yellow', timestamp: Date.now(), blockId, + }; + annotations.push(ann); + renderHighlights(); + renderSidebarContent(); + notifyCount(); + void saveAnnotations(); + + const rect = range.getBoundingClientRect(); + popupEl = document.createElement('div'); + popupEl.className = 'remark-popup'; + popupEl.innerHTML = buildPopupHTML(selectedText); + + document.body.appendChild(popupEl); + + // Position below selection + const popupRect = popupEl.getBoundingClientRect(); + let top = rect.bottom + 8; + let left = rect.left + (rect.width / 2) - (popupRect.width / 2); + + if (left < 8) left = 8; + if (left + popupRect.width > window.innerWidth - 8) { + left = window.innerWidth - popupRect.width - 8; + } + if (top + popupRect.height > window.innerHeight - 8) { + top = rect.top - popupRect.height - 8; + } + + popupEl.style.top = `${top}px`; + popupEl.style.left = `${left}px`; + + // Wire color buttons — change existing annotation's color + const colorBtns = popupEl.querySelectorAll('.remark-color-btn'); + colorBtns.forEach(btn => { + btn.addEventListener('click', () => { + colorBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + ann.color = btn.dataset.color as RemarkColor; + renderHighlights(); + renderSidebarContent(); + void saveAnnotations(); + }); + }); + + const cancelBtn = popupEl.querySelector('.remark-cancel-btn'); + const noteInput = popupEl.querySelector('.remark-note-input'); + + // Cancel → delete the just-created annotation + cancelBtn?.addEventListener('click', () => { + annotations = annotations.filter(a => a.id !== annId); + renderHighlights(); + renderSidebarContent(); + notifyCount(); + void saveAnnotations(); + hidePopup(); + window.getSelection()?.removeAllRanges(); + }); + + // Save note on Enter (without shift) + noteInput?.addEventListener('keydown', (ke) => { + if (ke.key === 'Enter' && !ke.shiftKey) { + ke.preventDefault(); + ann.note = noteInput.value.trim(); + renderSidebarContent(); + void saveAnnotations(); + hidePopup(); + window.getSelection()?.removeAllRanges(); + } + }); + + setTimeout(() => noteInput?.focus(), 50); + + + // Click outside → save note and close (annotation persists) + const outsideHandler = (e: MouseEvent) => { + if (popupEl && !popupEl.contains(e.target as Node)) { + if (noteInput) { + ann.note = noteInput.value.trim(); + renderSidebarContent(); + void saveAnnotations(); + } + hidePopup(); + document.removeEventListener('mousedown', outsideHandler); + } + }; + setTimeout(() => document.addEventListener('mousedown', outsideHandler), 100); + } + + function buildPopupHTML(selectedText: string): string { + const preview = escapeHtml(truncate(selectedText, 80)); + + return ` +
+ "${preview}" +
+
+ ${(Object.keys(COLOR_MAP) as RemarkColor[]).map((c, i) => ` + + `).join('')} +
+ +
+ +
+ `; + } + + function hidePopup(): void { + if (popupEl) { + popupEl.remove(); + popupEl = null; + } + // Remove any temporary block highlight + document.querySelectorAll('.remark-popup-target') + .forEach(el => el.classList.remove('remark-popup-target')); + } + + // ─── Annotations ─────────────────────────────────────────────────────────── + + function notifyCount(): void { + onAnnotationCountChange?.(annotations.length); + } + + function addAnnotation( + selectedText: string, note: string, color: RemarkColor, + startLine: number, endLine: number, blockId?: string + ): void { + const ann: RemarkAnnotation = { + id: generateId(), + startLine, + endLine, + selectedText, + note, + color, + timestamp: Date.now(), + blockId, + }; + annotations.push(ann); + renderHighlights(); + renderSidebarContent(); + notifyCount(); + void saveAnnotations(); + } + + function removeAnnotation(id: string): void { + const ann = annotations.find(a => a.id === id); + if (!ann) return; + // Soft-delete: hide item and show undo toast with countdown + const item = sidebarEl?.querySelector(`.remark-sidebar-item[data-ann-id="${id}"]`); + if (item) { + item.style.opacity = '0.3'; + item.style.pointerEvents = 'none'; + const UNDO_SECONDS = 5; + let remaining = UNDO_SECONDS; + // Show inline undo row with countdown and progress bar + const undo = document.createElement('div'); + undo.className = 'remark-undo-row'; + undo.setAttribute('role', 'status'); + undo.setAttribute('aria-live', 'polite'); + undo.innerHTML = `${t('remark_deleted', 'Deleted')}
${remaining}s
`; + item.after(undo); + const countdownEl = undo.querySelector('.remark-undo-countdown')!; + let committed = false; + const tick = setInterval(() => { + remaining--; + if (remaining > 0) { + countdownEl.textContent = `${remaining}s`; + } else { + clearInterval(tick); + } + }, 1000); + const commit = () => { + if (committed) return; + committed = true; + clearInterval(tick); + undo.remove(); + annotations = annotations.filter(a => a.id !== id); + renderHighlights(); + renderSidebarContent(); + notifyCount(); + void saveAnnotations(); + }; + undo.querySelector('.remark-undo-btn')?.addEventListener('click', () => { + if (committed) return; + committed = true; + clearInterval(tick); + clearTimeout(timer); + undo.remove(); + item.style.opacity = ''; + item.style.pointerEvents = ''; + }); + const timer = setTimeout(commit, UNDO_SECONDS * 1000); + } else { + // Fallback: immediate delete (sidebar not rendered) + annotations = annotations.filter(a => a.id !== id); + renderHighlights(); + renderSidebarContent(); + notifyCount(); + void saveAnnotations(); + } + } + + function updateAnnotationNote(id: string, note: string): void { + const ann = annotations.find(a => a.id === id); + if (!ann) return; + ann.note = note; + renderSidebarContent(); + void saveAnnotations(); + } + + function getAnnotations(): RemarkAnnotation[] { + return [...annotations]; + } + + // ─── Sidebar ─────────────────────────────────────────────────────────────── + + function showSidebar(): void { + const el = document.getElementById('remark-sidebar'); + if (!el) return; + sidebarEl = el; + + // Always inject fresh content (innerHTML is cleared after hide transition) + el.innerHTML = ` +
+ ${t('remark_sidebar_title', 'Remarks')} +
+ + +
+
+
+ `; + + el.classList.remove('remark-sidebar-closed'); + + // Wire export button: copy and reset (no auto-exit, allows repeated copy) + const exportBtn = el.querySelector('.remark-sidebar-export'); + exportBtn?.addEventListener('click', async () => { + const result = await exportToClipboard(); + if (exportBtn) { + if (result.ok) { + exportBtn.textContent = `✅ ${t('remark_copied', 'Copied!')}`; + exportBtn.disabled = true; + setTimeout(() => { + exportBtn.textContent = `📋 ${t('remark_copy_btn', 'Copy remarks')}`; + exportBtn.disabled = false; + }, 2000); + } else { + exportBtn.textContent = `⚠️ ${t('remark_copy_failed', 'Failed')}`; + setTimeout(() => { exportBtn.textContent = `📋 ${t('remark_copy_btn', 'Copy remarks')}`; exportBtn.disabled = false; }, 2000); + } + } + }); + + // Wire clear-all button (two-click confirmation) + const clearBtn = el.querySelector('.remark-sidebar-clear'); + if (clearBtn) { + let confirmPending = false; + clearBtn.addEventListener('click', () => { + if (annotations.length === 0) return; + if (!confirmPending) { + confirmPending = true; + clearBtn.textContent = '⚠️'; + clearBtn.title = t('remark_confirm_clear', 'Click again to confirm'); + clearBtn.classList.add('remark-confirm'); + setTimeout(() => { + confirmPending = false; + clearBtn.textContent = '🗑️'; + clearBtn.title = t('remark_clear_all', 'Clear all remarks'); + clearBtn.classList.remove('remark-confirm'); + }, 3000); + return; + } + confirmPending = false; + annotations = []; + renderHighlights(); + renderSidebarContent(); + notifyCount(); + void saveAnnotations(); + }); + } + + renderSidebarContent(); + } + + function hideSidebar(): void { + if (sidebarEl) { + sidebarEl.classList.add('remark-sidebar-closed'); + // Remove margin immediately so it transitions simultaneously with the sidebar slide-out + document.body.classList.remove('remark-panel-open'); + const el = sidebarEl; + sidebarEl = null; + const onDone = (): void => { + el.removeEventListener('transitionend', onDone); + el.innerHTML = ''; // Clear content after slide-out so next enter creates fresh HTML + }; + el.addEventListener('transitionend', onDone, { once: true }); + setTimeout(onDone, 400); + } + } + + function renderSidebarContent(): void { + if (!sidebarEl) return; + const list = sidebarEl.querySelector('.remark-sidebar-list'); + const countEl = sidebarEl.querySelector('.remark-sidebar-count'); + if (!list) return; + + // Update count badge in header + if (countEl) { + countEl.textContent = annotations.length > 0 ? `(${annotations.length})` : ''; + } + + if (annotations.length === 0) { + list.innerHTML = `
${t('remark_empty_hint', 'Select text to add remarks')}
`; + return; + } + + const sorted = [...annotations].sort((a, b) => a.startLine - b.startLine); + list.innerHTML = sorted.map(ann => { + const lineRef = formatLineRef(ann.startLine, ann.endLine); + const quote = escapeHtml(truncate(ann.selectedText, 50)); + const noteHtml = ann.note + ? `
${escapeHtml(ann.note)}
` + : `
${t('remark_add_note', 'Add a note…')}
`; + + return ` +
+
+ ${COLOR_MAP[ann.color].emoji} ${lineRef} + +
+
"${quote}"
+ ${noteHtml} +
+ `; + }).join(''); + + const focusAnnotationFromSidebar = (id: string): void => { + const ann = annotations.find(a => a.id === id); + if (!ann) return; + + const container = getContainer(); + if (container) { + const block = container.querySelector(`[data-line="${ann.startLine}"]`) as HTMLElement | null; + if (block) block.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + const targetItem = list.querySelector(`.remark-sidebar-item[data-ann-id="${id}"]`); + const noteEl = targetItem?.querySelector('[data-editable]') as HTMLElement | null; + if (noteEl) noteEl.click(); + }; + + const requestAnnotationFocus = (id: string, event?: MouseEvent): void => { + const activeEditor = sidebarEl?.querySelector('.remark-sidebar-note-editor') as HTMLTextAreaElement | null; + const activeItemId = activeEditor?.closest('.remark-sidebar-item')?.getAttribute('data-ann-id') || null; + + if (activeEditor && activeItemId !== id) { + pendingFocusId = id; + event?.preventDefault(); + activeEditor.blur(); + return; + } + + focusAnnotationFromSidebar(id); + }; + + // Wire delete buttons + list.querySelectorAll('.remark-sidebar-delete').forEach(btn => { + btn.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const id = (btn as HTMLElement).dataset.annId; + if (id) removeAnnotation(id); + }); + }); + + // Wire click-to-scroll (on header/quote area, not note) + list.querySelectorAll('.remark-sidebar-item').forEach(item => { + const header = item.querySelector('.remark-sidebar-item-header'); + const quote = item.querySelector('.remark-sidebar-quote'); + [header, quote].forEach(el => { + el?.addEventListener('mousedown', (e) => { + if ((e.target as HTMLElement | null)?.closest?.('.remark-sidebar-delete')) return; + const id = (item as HTMLElement).dataset.annId; + if (!id) return; + requestAnnotationFocus(id, e); + }); + }); + + // Wire inline note editing + const noteEl = item.querySelector('[data-editable]') as HTMLElement | null; + noteEl?.addEventListener('mousedown', (e) => { + const id = (item as HTMLElement).dataset.annId; + if (!id) return; + const activeEditor = sidebarEl?.querySelector('.remark-sidebar-note-editor') as HTMLTextAreaElement | null; + const activeItemId = activeEditor?.closest('.remark-sidebar-item')?.getAttribute('data-ann-id') || null; + if (activeEditor && activeItemId !== id) { + pendingFocusId = id; + e.preventDefault(); + activeEditor.blur(); + } + }); + noteEl?.addEventListener('click', (e) => { + e.stopPropagation(); + const id = (item as HTMLElement).dataset.annId; + if (!id) return; + const ann = annotations.find(a => a.id === id); + if (!ann) return; + + // Replace with textarea + const ta = document.createElement('textarea'); + ta.className = 'remark-sidebar-note-editor'; + ta.value = ann.note; + ta.placeholder = 'Add a note…'; + ta.rows = 1; + noteEl.replaceWith(ta); + + // Auto-expand textarea up to 5 lines + const autoResize = (): void => { + ta.style.height = 'auto'; + const lineHeight = parseInt(getComputedStyle(ta).lineHeight) || 18; + const maxH = lineHeight * 5 + 12; // 5 lines + padding + ta.style.height = `${Math.min(ta.scrollHeight, maxH)}px`; + ta.style.overflow = ta.scrollHeight > maxH ? 'auto' : 'hidden'; + }; + ta.addEventListener('input', autoResize); + // Initial auto-resize after DOM insertion + requestAnimationFrame(autoResize); + + ta.focus(); + // Place cursor at end without selecting text + const len = ta.value.length; + ta.setSelectionRange(len, len); + + const saveEdit = (): void => { + const newNote = ta.value.trim(); + updateAnnotationNote(id, newNote); + // renderSidebarContent is called inside updateAnnotationNote + }; + + ta.addEventListener('blur', saveEdit); + ta.addEventListener('keydown', (ke) => { + if (ke.key === 'Enter' && !ke.shiftKey) { + ke.preventDefault(); + ta.blur(); + } + if (ke.key === 'Escape') { + ta.removeEventListener('blur', saveEdit); + renderSidebarContent(); + } + }); + }); + }); + + // Handle pending focus from click chain (when clicking note B while A was editing) + if (pendingFocusId) { + const focusId = pendingFocusId; + pendingFocusId = null; + setTimeout(() => focusAnnotationFromSidebar(focusId), 0); + } + } + + // ─── Highlights ──────────────────────────────────────────────────────────── + + function renderHighlights(): void { + clearHighlights(); + const container = getContainer(); + if (!container) return; + + for (const ann of annotations) { + const blocks = container.querySelectorAll('[data-line]'); + for (const block of blocks) { + const { start: blockLine, end: blockEnd } = getBlockRange(block); + + if (rangesOverlap(blockLine, blockEnd, ann.startLine, ann.endLine)) { + block.classList.add('remark-highlighted'); + block.style.setProperty('--remark-bg', COLOR_MAP[ann.color].bg); + block.style.setProperty('--remark-border', COLOR_MAP[ann.color].border); + + if (!block.querySelector(`.remark-badge[data-ann-id="${ann.id}"]`)) { + const badge = document.createElement('span'); + badge.className = 'remark-badge'; + badge.dataset.annId = ann.id; + badge.textContent = '✕'; + badge.title = `${t('remark_delete', 'Delete')}: ${ann.note || COLOR_LABELS[ann.color]}`; + badge.style.color = COLOR_MAP[ann.color].border; + block.style.position = 'relative'; + badge.addEventListener('click', (e) => { + e.stopPropagation(); + removeAnnotation(ann.id); + }); + block.appendChild(badge); + } + } + } + } + } + + function clearHighlights(): void { + const container = getContainer(); + if (!container) return; + + container.querySelectorAll('.remark-highlighted').forEach(el => { + el.classList.remove('remark-highlighted'); + (el as HTMLElement).style.removeProperty('--remark-bg'); + (el as HTMLElement).style.removeProperty('--remark-border'); + }); + container.querySelectorAll('.remark-badge').forEach(el => el.remove()); + } + + // ─── Export ──────────────────────────────────────────────────────────────── + + function formatExport(): string { + if (annotations.length === 0) return ''; + + const activeUrl = getCurrentDocumentUrl(); + const viewerFilePath = document.documentElement.dataset.viewerFilePath; + let filePath = viewerFilePath + || document.title + || decodeURIComponent(window.location.pathname); + + try { + const url = new URL(activeUrl); + if (url.protocol === 'file:') { + filePath = viewerFilePath || decodeURIComponent(url.pathname); + } else { + filePath = document.title || decodeURIComponent(url.pathname); + } + } catch { + // Keep the best-effort fallback above. + } + + return formatExportText(annotations, filePath); + } + + async function exportToClipboard(): Promise<{ ok: boolean; reason?: string }> { + const text = formatExport(); + if (!text) return { ok: false, reason: 'No annotations to export' }; + + try { + await navigator.clipboard.writeText(text); + return { ok: true }; + } catch { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + ta.remove(); + return { ok: true }; + } catch (e) { + return { ok: false, reason: (e as Error).message }; + } + } + } + + function dispose(): void { + exit(); + annotations = []; + } + + // ─── Utilities ───────────────────────────────────────────────────────────── + + function generateId(): string { + return `rmk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; + } + + // ─── Styles ──────────────────────────────────────────────────────────────── + + let stylesInjected = false; + + function injectStyles(): void { + if (stylesInjected) return; + stylesInjected = true; + + const style = document.createElement('style'); + style.id = 'remark-mode-styles'; + style.textContent = ` + .remark-mode-active { + cursor: text; + } + .remark-mode-active [data-line][data-block-id]:not(:has(img, svg, canvas, figure, video)):hover { + outline: 1px dashed var(--color-primary, #2563eb); + outline-offset: 2px; + border-radius: 3px; + } + /* Temporary highlight on the block being annotated */ + .remark-popup-target { + outline: 2px dashed var(--color-primary, #2563eb) !important; + outline-offset: 3px; + border-radius: 3px; + background: var(--color-primary-subtle, rgba(37, 99, 235, 0.06)); + } + .remark-highlighted { + background: var(--remark-bg, rgba(250, 204, 21, 0.15)); + border-left: 3px solid var(--remark-border, rgba(250, 204, 21, 0.6)); + padding-left: 8px; + border-radius: 3px; + transition: background 0.2s; + cursor: pointer; + } + .remark-badge { + position: absolute; + top: 2px; + right: -24px; + font-size: 11px; + font-weight: 700; + cursor: pointer; + user-select: none; + opacity: 0; + transition: opacity 0.15s, background 0.15s; + width: 16px; + height: 16px; + line-height: 16px; + text-align: center; + border-radius: 50%; + background: var(--gray-100, #f3f4f6); + } + .remark-highlighted:hover .remark-badge, + .remark-badge:hover { + opacity: 1; + } + .remark-badge:hover { + background: rgba(239, 68, 68, 0.15); + color: #ef4444 !important; + transform: scale(1.1); + } + + /* Tooltip */ + .remark-tooltip { + position: fixed; + z-index: 10002; + background: var(--color-bg-surface, #fff); + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,0.12); + padding: 8px 12px; + max-width: 300px; + font-size: 12px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: var(--color-text-primary, #1a1a1a); + pointer-events: auto; + } + .remark-tooltip-item { + padding: 2px 0; + line-height: 1.4; + } + .remark-tooltip-item em { + color: var(--gray-500, #6b7280); + } + + /* Sidebar inner layout (positioning/size handled by #remark-sidebar in styles.css) */ + .remark-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border, #e2e8f0); + flex-shrink: 0; + } + .remark-sidebar-title { + font-weight: 600; + font-size: 14px; + } + .remark-sidebar-export { + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 6px; + background: var(--gray-50, #f9fafb); + padding: 4px 10px; + cursor: pointer; + font-size: 12px; + color: inherit; + transition: background 0.15s; + } + .remark-sidebar-export:hover { + background: var(--gray-200, #e5e7eb); + } + .remark-sidebar-actions { + display: flex; + gap: 4px; + align-items: center; + } + .remark-sidebar-clear { + border: 1px solid transparent; + border-radius: 6px; + background: none; + padding: 4px 6px; + cursor: pointer; + font-size: 14px; + color: var(--gray-400, #9ca3af); + transition: color 0.15s, background 0.15s; + } + .remark-sidebar-clear:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.08); + } + .remark-sidebar-clear.remark-confirm { + color: #ef4444; + border-color: #ef4444; + animation: remark-pulse 1s ease-in-out infinite; + } + @keyframes remark-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + .remark-sidebar-list { + flex: 1; + overflow-y: auto; + padding: 8px; + } + .remark-sidebar-empty { + color: var(--gray-400, #9ca3af); + text-align: center; + padding: 24px 16px; + font-style: italic; + } + .remark-sidebar-item { + padding: 10px 12px; + border-radius: 6px; + border: 1px solid var(--color-border, #e2e8f0); + margin-bottom: 8px; + cursor: pointer; + transition: background 0.15s; + } + .remark-sidebar-item:hover { + background: var(--gray-50, #f9fafb); + } + .remark-sidebar-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + } + .remark-sidebar-delete { + border: none; + background: none; + cursor: pointer; + color: var(--gray-400, #9ca3af); + font-size: 14px; + padding: 2px 6px; + border-radius: 4px; + transition: color 0.15s, background 0.15s; + } + .remark-sidebar-delete:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); + } + /* Undo row after soft-deleted item */ + .remark-undo-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + margin-bottom: 8px; + border-radius: 6px; + background: var(--gray-100, #f3f4f6); + font-size: 12px; + color: var(--gray-500, #6b7280); + position: relative; + overflow: hidden; + } + .remark-undo-btn { + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 4px; + background: var(--color-bg-surface, #fff); + cursor: pointer; + font-size: 11px; + padding: 2px 8px; + color: var(--color-primary, #2563eb); + } + .remark-undo-btn:hover { background: var(--gray-50, #f9fafb); } + .remark-undo-actions { + display: flex; + align-items: center; + gap: 6px; + } + .remark-undo-countdown { + font-size: 10px; + color: var(--gray-400, #9ca3af); + font-variant-numeric: tabular-nums; + min-width: 18px; + text-align: right; + } + .remark-sidebar-quote { + font-style: italic; + color: var(--gray-500, #6b7280); + font-size: 12px; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .remark-sidebar-note { + margin-top: 4px; + font-size: 12px; + color: var(--color-text-primary, #1a1a1a); + background: var(--gray-50, #f9fafb); + padding: 4px 8px; + border-radius: 4px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 5; + -webkit-box-orient: vertical; + line-height: 18px; + } + + /* Popup */ + .remark-popup { + position: fixed; + z-index: 10001; + background: var(--color-bg-surface, #fff); + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.15); + padding: 12px; + width: 320px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + color: var(--color-text-primary, #1a1a1a); + } + .remark-popup-header { + margin-bottom: 8px; + } + .remark-popup-quote { + font-style: italic; + color: var(--gray-500, #6b7280); + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .remark-popup-colors { + display: flex; + gap: 6px; + margin-bottom: 8px; + } + .remark-color-btn { + border: 2px solid transparent; + border-radius: 6px; + background: var(--gray-100, #f3f4f6); + padding: 4px 8px; + cursor: pointer; + font-size: 16px; + transition: border-color 0.15s; + } + .remark-color-btn:hover { + background: var(--gray-200, #e5e7eb); + } + .remark-color-btn.active { + border-color: var(--color-primary, #2563eb); + background: var(--color-primary-light, #eff6ff); + } + .remark-note-input { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 6px; + padding: 8px; + font-size: 13px; + font-family: inherit; + resize: vertical; + min-height: 48px; + margin-bottom: 8px; + color: inherit; + background: var(--gray-50, #f9fafb); + } + .remark-note-input:focus { + outline: none; + border-color: var(--color-primary, #2563eb); + box-shadow: 0 0 0 2px var(--color-primary-subtle, #dbeafe); + } + .remark-popup-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } + .remark-popup-actions button { + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + border: 1px solid var(--color-border, #e2e8f0); + background: var(--gray-50, #f9fafb); + color: inherit; + transition: background 0.15s; + } + .remark-popup-actions button:hover { + background: var(--gray-200, #e5e7eb); + } + .remark-save-btn { + background: var(--color-primary, #2563eb) !important; + color: #fff !important; + border-color: var(--color-primary, #2563eb) !important; + } + .remark-save-btn:hover { + background: var(--color-primary-hover, #1d4ed8) !important; + } + + /* Toolbar button active state */ + .toolbar-btn.remark-active { + background: var(--color-primary-light, #eff6ff); + color: var(--color-primary, #2563eb); + } + + /* Count badge on toolbar button */ + .remark-count-badge { + position: absolute; + top: 2px; + right: 2px; + background: #6b7280; + color: #fff; + font-size: 9px; + font-weight: 700; + min-width: 14px; + height: 14px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; + line-height: 1; + pointer-events: none; + } + + /* Sidebar note editable */ + .remark-sidebar-note[data-editable] { + cursor: pointer; + } + .remark-note-placeholder { + color: var(--gray-400, #9ca3af) !important; + font-style: italic; + } + .remark-sidebar-note-editor { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--color-primary, #2563eb); + border-radius: 4px; + padding: 4px 6px; + font-size: 12px; + font-family: inherit; + resize: none; + overflow: hidden; + margin-top: 4px; + background: var(--color-bg-surface, #fff); + color: var(--color-text-primary, #1a1a1a); + box-shadow: 0 0 0 2px var(--color-primary-subtle, #dbeafe); + line-height: 18px; + } + .remark-sidebar-count { + color: var(--gray-500, #6b7280); + font-weight: normal; + font-size: 13px; + } + + /* ── UX Delight: Animations ─────────────────────────────── */ + @media (prefers-reduced-motion: no-preference) { + /* Popup scale-in */ + .remark-popup { + animation: remark-popup-in 0.15s cubic-bezier(0.34, 1.56, 0.64, 1); + transform-origin: top center; + } + @keyframes remark-popup-in { + from { opacity: 0; transform: scale(0.9) translateY(-4px); } + to { opacity: 1; transform: scale(1) translateY(0); } + } + + /* Color button ink splash on selection */ + .remark-color-btn.active { + animation: remark-ink 0.3s ease-out; + } + @keyframes remark-ink { + 0% { box-shadow: 0 0 0 0 var(--color-primary-subtle, #dbeafe); } + 70% { box-shadow: 0 0 0 6px transparent; } + 100% { box-shadow: none; } + } + + /* Undo progress bar shrink */ + .remark-undo-progress { + animation: remark-shrink linear forwards; + } + @keyframes remark-shrink { + from { width: 100%; } + to { width: 0%; } + } + } + + /* Undo progress bar base style */ + .remark-undo-progress { + position: absolute; + bottom: 0; + left: 0; + height: 2px; + width: 100%; + background: var(--color-primary, #2563eb); + opacity: 0.4; + pointer-events: none; + } + + /* Color button labels */ + .remark-color-label { + font-size: 11px; + vertical-align: middle; + opacity: 0.8; + } + + `; + document.head.appendChild(style); + } + + // After loading, notify toolbar of initial count + const _loadAnnotations = loadAnnotations; + async function loadAnnotationsAndNotify(): Promise { + await _loadAnnotations(); + notifyCount(); + } + + return { + isActive, + enter, + exit, + getAnnotations, + removeAnnotation, + updateAnnotationNote, + exportToClipboard, + loadAnnotations: loadAnnotationsAndNotify, + dispose, + }; +} diff --git a/src/ui/remark-utils.ts b/src/ui/remark-utils.ts new file mode 100644 index 00000000..6a47a02a --- /dev/null +++ b/src/ui/remark-utils.ts @@ -0,0 +1,143 @@ +/** + * Pure utility functions for Remark Mode. + * + * These functions have no DOM, chrome.*, or side-effect dependencies + * so they can be imported directly in unit tests. + */ + +// ─── Types (re-exported for test convenience) ──────────────────────────────── + +export type RemarkColor = 'yellow' | 'green' | 'blue' | 'pink'; + +export interface RemarkAnnotation { + id: string; + startLine: number; + endLine: number; + selectedText: string; + note: string; + color: RemarkColor; + timestamp: number; + blockId?: string; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +export const COLOR_MAP: Record = { + yellow: { emoji: '🟡', bg: 'rgba(250, 204, 21, 0.2)', border: 'rgba(250, 204, 21, 0.6)' }, + green: { emoji: '🟢', bg: 'rgba(74, 222, 128, 0.2)', border: 'rgba(74, 222, 128, 0.6)' }, + blue: { emoji: '🔵', bg: 'rgba(96, 165, 250, 0.2)', border: 'rgba(96, 165, 250, 0.6)' }, + pink: { emoji: '🩷', bg: 'rgba(244, 114, 182, 0.2)', border: 'rgba(244, 114, 182, 0.6)' }, +}; + +export const COLOR_LABELS: Record = { + yellow: 'Suggestion', + green: 'Keep', + blue: 'Question', + pink: 'Concern', +}; + +// Tags that should not be annotatable (images, charts, media) +export const SKIP_ANNOTATION_TAGS = new Set(['IMG', 'SVG', 'CANVAS', 'VIDEO', 'AUDIO', 'IFRAME']); + +// ─── Pure functions ────────────────────────────────────────────────────────── + +/** + * Width-aware text truncation. CJK characters and fullwidth punctuation count + * as 2 units of width; everything else counts as 1. When the text exceeds + * `maxWidth`, it is cut and an ellipsis `…` (width 1) is appended. + */ +export function truncate(str: string, maxWidth: number): string { + if (!str) return str; + // First pass: measure total width + let totalWidth = 0; + for (const ch of str) { + totalWidth += /[\u2E80-\u9FFF\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFFEF\u3000-\u303F]/.test(ch) ? 2 : 1; + } + if (totalWidth <= maxWidth) return str; // fits, no truncation needed + + // Needs truncation: cut to (maxWidth - 1) and append ellipsis + const limit = maxWidth - 1; + let width = 0; + let cutIndex = 0; + for (const ch of str) { + const w = /[\u2E80-\u9FFF\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFFEF\u3000-\u303F]/.test(ch) ? 2 : 1; + if (width + w > limit) { + return str.slice(0, cutIndex) + '…'; + } + width += w; + cutIndex += ch.length; + } + return str.slice(0, cutIndex) + '…'; +} + +/** Format a line reference: `L5` for single line, `L5–L10` for a range. */ +export function formatLineRef(startLine: number, endLine: number): string { + return startLine === endLine ? `L${startLine}` : `L${startLine}–L${endLine}`; +} + +/** Read the line range of a rendered markdown block element. */ +export function getBlockRange(el: HTMLElement): { start: number; end: number } { + const start = Number(el.getAttribute('data-line')) || 0; + const count = Number(el.getAttribute('data-line-count')) || 1; + return { start, end: start + count - 1 }; +} + +/** Check whether two inclusive integer ranges [aS, aE] and [bS, bE] overlap. */ +export function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { + return aStart <= bEnd && aEnd >= bStart; +} + +/** Check if a block element is a media/image block that should not be annotatable. */ +export function isMediaBlock(el: HTMLElement): boolean { + if (SKIP_ANNOTATION_TAGS.has(el.tagName)) return true; + return !!(el.querySelector('img, svg, canvas, video, figure, picture')); +} + +/** + * Pure-function version of export formatting. + * Takes annotations + filePath, returns structured prompt text. + */ +export function formatExportText( + annotations: readonly RemarkAnnotation[], + filePath: string, +): string { + if (annotations.length === 0) return ''; + + const sorted = [...annotations].sort((a, b) => a.startLine - b.startLine); + + const groups: { key: string; lineRef: string; anns: RemarkAnnotation[] }[] = []; + for (const ann of sorted) { + const key = `${ann.startLine}-${ann.endLine}`; + const lineRef = formatLineRef(ann.startLine, ann.endLine); + const last = groups[groups.length - 1]; + if (last && last.key === key) { + last.anns.push(ann); + } else { + groups.push({ key, lineRef, anns: [ann] }); + } + } + + const lines: string[] = []; + lines.push(`I reviewed **${filePath}** and have the following feedback:\n`); + + for (let i = 0; i < groups.length; i++) { + const { lineRef, anns } = groups[i]; + for (let j = 0; j < anns.length; j++) { + const ann = anns[j]; + const label = COLOR_LABELS[ann.color]; + const quote = truncate(ann.selectedText, 120); + + if (j === 0) { + let line = `${i + 1}. [${COLOR_MAP[ann.color].emoji} ${label}] ${lineRef}: "${quote}"`; + if (ann.note) line += `\n Note: "${ann.note}"`; + lines.push(line); + } else { + let line = ` [${COLOR_MAP[ann.color].emoji} ${label}] "${quote}"`; + if (ann.note) line += `\n Note: "${ann.note}"`; + lines.push(line); + } + } + } + + return lines.join('\n'); +} diff --git a/src/ui/styles.css b/src/ui/styles.css index 2ba6f35c..ed162369 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -562,6 +562,7 @@ body.sidebar-resizing { overflow-x: hidden; overscroll-behavior: contain; scroll-padding-top: 12px; + transition: margin-left 0.2s ease, margin-right 0.2s ease; } #markdown-wrapper:focus { @@ -583,6 +584,54 @@ body.toc-position-right.toc-hidden #markdown-wrapper { margin-right: 0; } +/* Remark sidebar: mirrors #table-of-contents pattern, but on the right by default */ +#remark-sidebar { + width: 280px; + background: var(--color-bg-toc); + border-left: 1px solid var(--color-border); + position: fixed; + top: 50px; + right: 0; + height: calc(100vh - 50px); + overflow-y: auto; + overscroll-behavior: contain; + transition: transform var(--transition-slow); + z-index: 1000; + display: flex; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + color: var(--color-text-primary, #1a1a1a); +} + +#remark-sidebar.remark-sidebar-closed { + transform: translateX(100%); +} + +/* When TOC is on the right, remark moves to the left */ +body.toc-position-right #remark-sidebar { + right: auto; + left: 0; + border-left: none; + border-right: 1px solid var(--color-border); +} + +body.toc-position-right #remark-sidebar.remark-sidebar-closed { + transform: translateX(-100%); +} + +/* Remark Mode: push content to make room for the annotation panel */ +body.remark-panel-open #markdown-wrapper { + margin-right: 280px; + transition: margin-right 0.2s ease; +} + +/* TOC on right + remark on left: remark panel uses margin-left */ +body.remark-panel-open.toc-position-right #markdown-wrapper { + margin-right: 280px; /* TOC */ + margin-left: 280px; /* remark */ +} + #markdown-page { max-width: 1060px; diff --git a/test/all.test.js b/test/all.test.js index bfe61e29..1216b47c 100644 --- a/test/all.test.js +++ b/test/all.test.js @@ -8,3 +8,4 @@ import './markdown-document.test.js'; import './docx-math-converter.test.js'; import './heading-numbering.test.ts'; import './remark-super-sub.test.js'; +import './remark-mode.test.ts'; diff --git a/test/remark-mode.test.ts b/test/remark-mode.test.ts new file mode 100644 index 00000000..00ef6be5 --- /dev/null +++ b/test/remark-mode.test.ts @@ -0,0 +1,286 @@ +/** + * Unit tests for remark-mode pure utility functions. + * + * Covers: truncate, formatLineRef, rangesOverlap, getBlockRange, + * isMediaBlock, formatExportText + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +import { + truncate, + formatLineRef, + rangesOverlap, + getBlockRange, + isMediaBlock, + formatExportText, +} from '../src/ui/remark-utils.ts'; + +// ─── truncate ──────────────────────────────────────────────────────────────── + +describe('truncate', () => { + it('short ASCII: returns unchanged', () => { + assert.strictEqual(truncate('hello', 10), 'hello'); + }); + + it('long ASCII: cuts at width boundary + ellipsis', () => { + const input = 'a'.repeat(130); + const result = truncate(input, 120); + assert.ok(result.endsWith('…')); + // 119 chars + ellipsis + assert.strictEqual(result.length, 120); + }); + + it('exact ASCII boundary: no truncation', () => { + const input = 'a'.repeat(120); + assert.strictEqual(truncate(input, 120), input); + }); + + it('one over ASCII boundary: truncates', () => { + const input = 'a'.repeat(121); + const result = truncate(input, 120); + assert.ok(result.endsWith('…')); + assert.strictEqual(result.length, 120); + }); + + it('pure CJK short: returns unchanged', () => { + assert.strictEqual(truncate('你好世界', 10), '你好世界'); // width 8 < 10 + }); + + it('pure CJK long: cuts respecting double width', () => { + const input = '中'.repeat(40); // width 80 + const result = truncate(input, 50); + assert.ok(result.endsWith('…')); + // Each 中 = width 2, limit = 49, so 24 chars fit (48 width) + … + assert.strictEqual(result, '中'.repeat(24) + '…'); + }); + + it('mixed CJK + ASCII', () => { + // "Hello你好World" → H(1)e(1)l(1)l(1)o(1)你(2)好(2)W(1)o(1)r(1)l(1)d(1) = 15 + const input = 'Hello你好World'; + const result = truncate(input, 12); + // limit = 11 width. "Hello你好" = 5+4 = 9, + "W" = 10, + "o" = 11 → fits + // + "r" = 12 → over limit + assert.strictEqual(result, 'Hello你好Wo…'); + }); + + it('empty string: returns empty', () => { + assert.strictEqual(truncate('', 10), ''); + }); + + it('very small maxWidth: only ellipsis', () => { + const result = truncate('abcde', 1); + assert.strictEqual(result, '…'); + }); + + it('CJK fullwidth punctuation counts as 2', () => { + // ,、。are fullwidth punctuation in CJK range + const input = '你好,世界。'; // width: 2+2+2+2+2+2 = 12 + const result = truncate(input, 10); + assert.ok(result.endsWith('…')); + // limit=9, 你(2)好(2),(2)世(2) = 8, 界(2) would be 10 > 9 + assert.strictEqual(result, '你好,世…'); + }); + + it('emoji: handled without breaking surrogate pairs', () => { + const input = '👍hello'; // 👍 is width 1 (non-CJK), h(1)e(1)l(1)l(1)o(1) = 7 + assert.strictEqual(truncate(input, 10), '👍hello'); + }); +}); + +// ─── formatLineRef ─────────────────────────────────────────────────────────── + +describe('formatLineRef', () => { + it('single line', () => { + assert.strictEqual(formatLineRef(5, 5), 'L5'); + }); + + it('line range', () => { + assert.strictEqual(formatLineRef(5, 10), 'L5–L10'); + }); + + it('line 1', () => { + assert.strictEqual(formatLineRef(1, 1), 'L1'); + }); +}); + +// ─── rangesOverlap ─────────────────────────────────────────────────────────── + +describe('rangesOverlap', () => { + it('b fully inside a', () => { + assert.strictEqual(rangesOverlap(5, 10, 6, 8), true); + }); + + it('left overlap', () => { + assert.strictEqual(rangesOverlap(5, 10, 3, 7), true); + }); + + it('right overlap', () => { + assert.strictEqual(rangesOverlap(5, 10, 8, 12), true); + }); + + it('no overlap: b after a', () => { + assert.strictEqual(rangesOverlap(5, 10, 11, 15), false); + }); + + it('right boundary touch', () => { + assert.strictEqual(rangesOverlap(5, 10, 10, 15), true); + }); + + it('left boundary touch', () => { + assert.strictEqual(rangesOverlap(5, 10, 1, 5), true); + }); + + it('off by one: no overlap', () => { + assert.strictEqual(rangesOverlap(5, 10, 1, 4), false); + }); +}); + +// ─── getBlockRange ─────────────────────────────────────────────────────────── + +function createElementStub(attrs: Record): HTMLElement { + return { + getAttribute(name: string) { return attrs[name] ?? null; }, + tagName: attrs._tagName || 'DIV', + querySelector() { return null; }, + } as unknown as HTMLElement; +} + +describe('getBlockRange', () => { + it('reads data-line and data-line-count', () => { + const el = createElementStub({ 'data-line': '5', 'data-line-count': '3' }); + assert.deepStrictEqual(getBlockRange(el), { start: 5, end: 7 }); + }); + + it('defaults line-count to 1', () => { + const el = createElementStub({ 'data-line': '5' }); + assert.deepStrictEqual(getBlockRange(el), { start: 5, end: 5 }); + }); + + it('handles line 0', () => { + const el = createElementStub({ 'data-line': '0', 'data-line-count': '1' }); + assert.deepStrictEqual(getBlockRange(el), { start: 0, end: 0 }); + }); +}); + +// ─── isMediaBlock ──────────────────────────────────────────────────────────── + +describe('isMediaBlock', () => { + it('IMG tag → true', () => { + const el = createElementStub({ _tagName: 'IMG' }); + assert.strictEqual(isMediaBlock(el), true); + }); + + it('div containing img → true', () => { + const el = { + tagName: 'DIV', + getAttribute() { return null; }, + querySelector(sel: string) { return sel.includes('img') ? {} : null; }, + } as unknown as HTMLElement; + assert.strictEqual(isMediaBlock(el), true); + }); + + it('plain paragraph → false', () => { + const el = createElementStub({ _tagName: 'P' }); + assert.strictEqual(isMediaBlock(el), false); + }); + + it('div containing figure → true', () => { + const el = { + tagName: 'DIV', + getAttribute() { return null; }, + querySelector(sel: string) { return sel.includes('figure') ? {} : null; }, + } as unknown as HTMLElement; + assert.strictEqual(isMediaBlock(el), true); + }); +}); + +// ─── formatExportText ──────────────────────────────────────────────────────── + +function makeAnn(overrides: Record = {}) { + return { + id: 'test-1', + startLine: 5, + endLine: 5, + selectedText: 'hello world', + note: '', + color: 'yellow' as const, + timestamp: 1000, + ...overrides, + }; +} + +describe('formatExportText', () => { + it('empty annotations → empty string', () => { + assert.strictEqual(formatExportText([], '/test.md'), ''); + }); + + it('single annotation with note', () => { + const result = formatExportText( + [makeAnn({ note: 'fix this' })], + '/path/to/file.md', + ); + assert.ok(result.includes('I reviewed **/path/to/file.md**')); + assert.ok(result.includes('[🟡 Suggestion] L5:')); + assert.ok(result.includes('"hello world"')); + assert.ok(result.includes('Note: "fix this"')); + }); + + it('cross-line annotation uses range format', () => { + const result = formatExportText( + [makeAnn({ startLine: 5, endLine: 10 })], + 'test.md', + ); + assert.ok(result.includes('L5–L10')); + }); + + it('multiple annotations: numbered and sorted', () => { + const result = formatExportText([ + makeAnn({ id: 'b', startLine: 10, endLine: 10 }), + makeAnn({ id: 'a', startLine: 5, endLine: 5 }), + ], 'test.md'); + const lines = result.split('\n'); + // Find numbered lines + const numbered = lines.filter(l => /^\d+\./.test(l)); + assert.strictEqual(numbered.length, 2); + assert.ok(numbered[0].includes('L5')); + assert.ok(numbered[1].includes('L10')); + }); + + it('same-line group: first numbered, rest indented', () => { + const result = formatExportText([ + makeAnn({ id: 'a', startLine: 5, endLine: 8, color: 'yellow' }), + makeAnn({ id: 'b', startLine: 5, endLine: 8, color: 'green' }), + ], 'test.md'); + const lines = result.split('\n'); + // First annotation in group has number + const firstAnn = lines.find(l => l.startsWith('1.')); + assert.ok(firstAnn, 'should have numbered item'); + // Second in group is indented, no number + const indented = lines.find(l => l.startsWith(' [🟢')); + assert.ok(indented, 'should have indented grouped item'); + }); + + it('no note: omits Note line', () => { + const result = formatExportText([makeAnn()], 'test.md'); + assert.ok(!result.includes('Note:')); + }); + + it('unordered input: output is sorted by startLine', () => { + const result = formatExportText([ + makeAnn({ id: 'c', startLine: 20, endLine: 20 }), + makeAnn({ id: 'a', startLine: 1, endLine: 1 }), + makeAnn({ id: 'b', startLine: 10, endLine: 10 }), + ], 'test.md'); + const numbered = result.split('\n').filter(l => /^\d+\./.test(l)); + assert.ok(numbered[0].includes('L1')); + assert.ok(numbered[1].includes('L10')); + assert.ok(numbered[2].includes('L20')); + }); + + it('filePath appears in header', () => { + const result = formatExportText([makeAnn()], '/Users/kyle/AGENTS.md'); + assert.ok(result.startsWith('I reviewed **/Users/kyle/AGENTS.md**')); + }); +});