From 553b24da3f67905183ec111678c22f6eb900ce00 Mon Sep 17 00:00:00 2001 From: Aswin Murugesh Date: Wed, 5 Aug 2026 10:51:25 +0530 Subject: [PATCH 1/4] Fix continous refresh of editor --- .../src/documents/realtime/hub.rs | 11 ++++ .../src/documents/realtime/redis/engine.rs | 17 ++++-- .../hooks/useCollaborativeDocument.ts | 15 +++++- app/src/features/edit-document/ui/Editor.tsx | 54 +++++++------------ 4 files changed, 56 insertions(+), 41 deletions(-) diff --git a/api/crates/infrastructure/src/documents/realtime/hub.rs b/api/crates/infrastructure/src/documents/realtime/hub.rs index 68723993..93f11c0d 100644 --- a/api/crates/infrastructure/src/documents/realtime/hub.rs +++ b/api/crates/infrastructure/src/documents/realtime/hub.rs @@ -343,6 +343,17 @@ impl Hub { txt_new.get_string(&txn) }; + // Autosave PUTs re-send markdown the room already has. Replacing the + // whole YText (delete + insert) and broadcasting would invalidate + // client editor decorations even when the string is unchanged. + { + let txt = room.doc.get_or_insert_text("content"); + let txn = room.doc.transact(); + if txt.get_string(&txn) == new_markdown { + return Ok(()); + } + } + let update_bytes = { let txt = room.doc.get_or_insert_text("content"); let mut txn = room.doc.transact_mut(); diff --git a/api/crates/infrastructure/src/documents/realtime/redis/engine.rs b/api/crates/infrastructure/src/documents/realtime/redis/engine.rs index 7334d201..b4af69d1 100644 --- a/api/crates/infrastructure/src/documents/realtime/redis/engine.rs +++ b/api/crates/infrastructure/src/documents/realtime/redis/engine.rs @@ -431,12 +431,21 @@ impl RealtimeEngineTrait for RedisRealtimeEngine { .hydration_service .hydrate(&uuid, HydrationOptions::default()) .await?; - let update_bytes = { + let new_markdown = { let txt_new = doc.get_or_insert_text("content"); let txn_new = doc.transact(); - let new_markdown = txt_new.get_string(&txn_new); - drop(txn_new); - + txt_new.get_string(&txn_new) + }; + // Autosave PUTs re-send markdown already present in the doc. Skip the + // YText wipe+rewrite so connected editors keep decorations intact. + { + let txt = hydrated.doc.get_or_insert_text("content"); + let txn = hydrated.doc.transact(); + if txt.get_string(&txn) == new_markdown { + return Ok(()); + } + } + let update_bytes = { let txt = hydrated.doc.get_or_insert_text("content"); let mut txn = hydrated.doc.transact_mut(); let len = txt.len(&txn); diff --git a/app/src/features/edit-document/hooks/useCollaborativeDocument.ts b/app/src/features/edit-document/hooks/useCollaborativeDocument.ts index 4a18cfe1..187c5f35 100644 --- a/app/src/features/edit-document/hooks/useCollaborativeDocument.ts +++ b/app/src/features/edit-document/hooks/useCollaborativeDocument.ts @@ -45,6 +45,7 @@ const pendingContentByDocumentId = new Map() const pendingContentFlushByDocumentId = new Map>() const pendingContentFlushTimersByDocumentId = new Map() const documentContentFlushRegistrations = new Map() +const lastFlushedContentByDocumentId = new Map() let pendingContentRevision = 0 const SHARE_TOKEN_VALIDATION_STALE_MS = 5 * 60 * 1000 const DOCUMENT_META_STALE_MS = 60 * 1000 @@ -53,6 +54,11 @@ const DOCUMENT_CONTENT_FLUSH_DELAY_MS = 1200 export function markDocumentContentDirty(documentId: string, content: string) { if (!documentId) return if (!documentContentFlushRegistrations.has(documentId)) return + // Save echoes can re-arm dirty with the same string. Skip identical content + // so we only PUT again after a real edit. + const pending = pendingContentByDocumentId.get(documentId) + if (pending?.content === content) return + if (lastFlushedContentByDocumentId.get(documentId) === content) return pendingContentByDocumentId.set(documentId, { content, revision: ++pendingContentRevision, @@ -66,6 +72,7 @@ function registerDocumentContentFlush(documentId: string, token: string | null) const current = documentContentFlushRegistrations.get(documentId) if (current?.token === token) { documentContentFlushRegistrations.delete(documentId) + lastFlushedContentByDocumentId.delete(documentId) } if (!pendingContentByDocumentId.has(documentId)) { clearPendingDocumentContentFlushTimer(documentId) @@ -113,8 +120,14 @@ function flushPendingDocumentContent(documentId: string, tokenOverride?: string return } + lastFlushedContentByDocumentId.set(documentId, entry.content) + const current = pendingContentByDocumentId.get(documentId) - if (!current || current.revision === entry.revision) { + if ( + !current || + current.revision === entry.revision || + current.content === entry.content + ) { pendingContentByDocumentId.delete(documentId) return } diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index c8c330fd..65154584 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -143,7 +143,6 @@ type RefmdEditorInstance = monacoNs.editor.IStandaloneCodeEditor & { __disposeCursor?: () => void __disposeMonacoMd?: () => void __disposeKeydown?: () => void - __disposeDirtyTracker?: () => void __readOnlyOverlay?: { widget: monacoNs.editor.IOverlayWidget domNode: HTMLElement @@ -355,6 +354,24 @@ export function MarkdownEditor(props: MarkdownEditorProps) { scheduleCommentDecorationRefresh() }, }) + // Persist only for local Yjs writes (typing, toolbar, plugins). Remote + // sync / save-echo applies are non-local and must not re-arm autosave. + useEffect(() => { + if (readOnly) return + const ytext = doc.getText('content') + const observer = (_event: Y.YTextEvent, transaction: Y.Transaction) => { + if (!transaction.local) return + markDocumentContentDirty(documentId, ytext.toString()) + } + ytext.observe(observer) + return () => { + try { + ytext.unobserve(observer) + } catch { + /* noop */ + } + } + }, [doc, documentId, readOnly]) const commentsQuery = useQuery( documentCommentsQuery(documentId, { token: shareToken }), ) @@ -524,9 +541,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { editor?.__disposeMonacoMd?.(), ) safeExecute('dispose keydown handler', () => editor?.__disposeKeydown?.()) - safeExecute('dispose dirty tracker', () => - editor?.__disposeDirtyTracker?.(), - ) safeExecute('dispose plugin decorations', () => { for (const ids of pluginDecorationIdsRef.current.values()) { try { @@ -760,27 +774,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { onMonacoMount(editor, monaco) ;(editor as any).__monaco = monaco setReadOnlyOverlay(editor as any, monaco as any, readOnly) - let userEditIntent = false - const markDirtyFromModel = () => { - if (readOnly) return - const value = editor.getModel()?.getValue() - if (typeof value === 'string') { - markDocumentContentDirty(documentId, value) - } - } - ;(editor as any).__refmdMarkDirty = markDirtyFromModel - try { - const modelChangeDispose = editor.onDidChangeModelContent(() => { - if (!userEditIntent) return - markDirtyFromModel() - }) - ;(editor as any).__disposeDirtyTracker = () => - safeExecute('dispose dirty tracker', () => - modelChangeDispose.dispose(), - ) - } catch (error) { - logEditorError('register dirty tracker', error) - } // Register wiki-link completion provider try { const disp = registerWikiLinkCompletion(monaco as any) @@ -828,10 +821,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { emitReadOnlyWarning() return } - if (!readOnly) { - userEditIntent = true - ;(editor as any).__refmdUserEditIntent = true - } const KeyCode = (monaco as any)?.KeyCode const isEnter = KeyCode ? e.keyCode === KeyCode.Enter @@ -924,7 +913,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { setReadOnlyOverlay, enableVimMode, brandedMonacoTheme, - documentId, ], ) @@ -1283,12 +1271,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { if (!nextEdits.length) return false const applied = editorInstance.executeEdits('refmd-plugin', nextEdits) editorInstance.pushUndoStop() - try { - ;(editorInstance as any).__refmdUserEditIntent = true - ;(editorInstance as any).__refmdMarkDirty?.() - } catch { - /* noop */ - } return applied } From 049a0fd8fce4d373f6e8d4de654c624885f4e896 Mon Sep 17 00:00:00 2001 From: Aswin Murugesh Date: Mon, 10 Aug 2026 10:22:58 +0530 Subject: [PATCH 2/4] Fix newline after each comment marker --- .../model/comments-store.test.ts | 10 +++++++ .../document-comments/model/comments-store.ts | 9 ++++++ .../document-comments/ui/CommentsPanel.tsx | 19 +++++++++++- app/src/features/edit-document/ui/Editor.tsx | 30 +++++++++++++++++++ .../features/edit-document/ui/EditorPane.tsx | 3 ++ app/src/styles.css | 1 + 6 files changed, 71 insertions(+), 1 deletion(-) diff --git a/app/src/features/document-comments/model/comments-store.test.ts b/app/src/features/document-comments/model/comments-store.test.ts index e9d1f750..2ee48efa 100644 --- a/app/src/features/document-comments/model/comments-store.test.ts +++ b/app/src/features/document-comments/model/comments-store.test.ts @@ -55,6 +55,16 @@ describe('comment markers', () => { ) }) + it('does not leave a blank line when a marker occupied its own line', () => { + const marker = buildCommentMarker('owned') + + expect(stripCommentMarkers(`alpha\n${marker}\nbeta`, [marker])).toBe( + 'alpha\nbeta', + ) + expect(stripCommentMarkers(`alpha\n${marker}`, [marker])).toBe('alpha') + expect(stripCommentMarkers(`${marker}\nbeta`, [marker])).toBe('beta') + }) + it('finds valid comment markers in content once', () => { const marker = buildCommentMarker('owned') const content = `${marker} text ${marker} ` diff --git a/app/src/features/document-comments/model/comments-store.ts b/app/src/features/document-comments/model/comments-store.ts index 5bd67078..d4f24618 100644 --- a/app/src/features/document-comments/model/comments-store.ts +++ b/app/src/features/document-comments/model/comments-store.ts @@ -72,6 +72,10 @@ export function parseCommentMarkerId(marker: string) { const COMMENT_MARKER_PATTERN = //g +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + export function findCommentMarkers(content: string) { const matches = content.match(COMMENT_MARKER_PATTERN) return matches ? Array.from(new Set(matches)) : [] @@ -94,6 +98,11 @@ export function stripCommentMarkers( for (const marker of markers) { if (!marker || seen.has(marker)) continue seen.add(marker) + const escaped = escapeRegExp(marker) + // Drop markers that occupy a whole line so we don't leave a blank line. + out = out.replace(new RegExp(`\\n${escaped}(?=\\n|$)`, 'g'), '') + out = out.replace(new RegExp(`^${escaped}\\n`), '') + out = out.replace(new RegExp(`^${escaped}$`), '') out = out.split(marker).join('') } return out diff --git a/app/src/features/document-comments/ui/CommentsPanel.tsx b/app/src/features/document-comments/ui/CommentsPanel.tsx index 5d29533e..df326dcb 100644 --- a/app/src/features/document-comments/ui/CommentsPanel.tsx +++ b/app/src/features/document-comments/ui/CommentsPanel.tsx @@ -132,7 +132,24 @@ function validateTags(tags: string[]) { function buildMarkerInsertionRange( selection: DocumentEditorSelection, + editor: DocumentEditorApi, ): DocumentEditorRange { + // Monaco full-line selections usually end at column 1 of the next line + // (they include the trailing newline). Inserting there parks the marker on + // that following line alone, which looks like a blank line once hidden. + if ( + selection.endColumn === 1 && + selection.endLineNumber > selection.startLineNumber + ) { + const endOffset = editor.getOffsetFromPosition({ + lineNumber: selection.endLineNumber, + column: 1, + }) + if (typeof endOffset === 'number' && endOffset > 0) { + const range = editor.getRangeFromOffset(endOffset - 1, 0) + if (range) return range + } + } return { startLineNumber: selection.endLineNumber, startColumn: selection.endColumn, @@ -371,7 +388,7 @@ export function CommentsPanel({ }) const inserted = editor.applyEdits([ { - range: buildMarkerInsertionRange(selection), + range: buildMarkerInsertionRange(selection, editor), text: marker, forceMoveMarkers: true, }, diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 65154584..1dfeb05b 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -99,6 +99,7 @@ const EMPTY_COMMENT_COMPOSER_STATE = { const MAX_COMPACT_COMMENT_MARKER_ID_LENGTH = 24 const ADJACENT_COMMENT_MARKER_PATTERN = /^/ const COMMENT_MARKER_PATTERN = //g +const LONE_COMMENT_MARKER_LINE_PATTERN = /\n()(?=\n|$)/g function shouldCompactCommentMarker(marker: string) { const markerId = parseCommentMarkerId(marker) @@ -1474,6 +1475,35 @@ export function MarkdownEditor(props: MarkdownEditorProps) { } }, [editorMountNonce, editorRef, emitReadOnlyWarning, readOnly]) + // Markers left alone on a line (common after full-line selections) render as + // blank lines once hidden. Pull them onto the previous line. + useEffect(() => { + if (readOnly || !documentEditorApi) return + const content = getEditorCommentContent() + const edits: Array<{ + range: DocumentEditorRange + text: string + forceMoveMarkers: boolean + }> = [] + for (const match of content.matchAll(LONE_COMMENT_MARKER_LINE_PATTERN)) { + const marker = match[1] + if (typeof match.index !== 'number') continue + const range = documentEditorApi.getRangeFromOffset( + match.index, + 1 + marker.length, + ) + if (!range) continue + edits.push({ range, text: marker, forceMoveMarkers: true }) + } + if (!edits.length) return + documentEditorApi.applyEdits(edits.reverse()) + }, [ + boundText, + documentEditorApi, + getEditorCommentContent, + readOnly, + ]) + useEffect(() => { if (readOnly || !documentEditorApi || !commentThreads.length) return diff --git a/app/src/features/edit-document/ui/EditorPane.tsx b/app/src/features/edit-document/ui/EditorPane.tsx index 0269ab82..8ab5758c 100644 --- a/app/src/features/edit-document/ui/EditorPane.tsx +++ b/app/src/features/edit-document/ui/EditorPane.tsx @@ -65,6 +65,9 @@ export default function EditorPane({ theme, onBeforeMount, readOnly, onMount, on minimap: { enabled: false }, glyphMargin: true, wordWrap: 'on', + // Comment markers are visually zero-width; without this, wrap still + // reserves their model width and leaves an empty soft-wrapped line. + disableMonospaceOptimizations: true, scrollBeyondLastLine: true, readOnly, domReadOnly: readOnly, diff --git a/app/src/styles.css b/app/src/styles.css index 4ecc5b18..febdd097 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -584,6 +584,7 @@ font-size: 0 !important; letter-spacing: 0 !important; line-height: 0 !important; + overflow: hidden !important; pointer-events: none !important; } From 383235d510c25fd931a96632bb17bbf5d6fb7560 Mon Sep 17 00:00:00 2001 From: Aswin Murugesh Date: Tue, 11 Aug 2026 10:02:43 +0530 Subject: [PATCH 3/4] Fix newline before and after the commented texT --- app/src/features/document-comments/index.ts | 2 + .../lib/thread-range.test.ts | 17 ++++++ .../document-comments/lib/thread-range.ts | 25 ++++++-- .../document-comments/model/comments-store.ts | 14 ++++- .../document-comments/ui/CommentsPanel.tsx | 18 ++++-- app/src/features/edit-document/ui/Editor.tsx | 59 +++++++++++++++---- .../features/edit-document/ui/EditorPane.tsx | 11 +++- app/src/styles.css | 12 +++- 8 files changed, 132 insertions(+), 26 deletions(-) diff --git a/app/src/features/document-comments/index.ts b/app/src/features/document-comments/index.ts index e2a3ec45..9da52645 100644 --- a/app/src/features/document-comments/index.ts +++ b/app/src/features/document-comments/index.ts @@ -1,6 +1,8 @@ export { CommentsPanel } from './ui/CommentsPanel' export { buildCommentMarker, + buildCommentMarkerInsertion, + COMMENT_MARKER_WRAP_BREAK, createCommentId, createCommentMarkerId, findUnknownCommentMarkers, diff --git a/app/src/features/document-comments/lib/thread-range.test.ts b/app/src/features/document-comments/lib/thread-range.test.ts index 98e5ecef..b1d9d990 100644 --- a/app/src/features/document-comments/lib/thread-range.test.ts +++ b/app/src/features/document-comments/lib/thread-range.test.ts @@ -64,6 +64,23 @@ describe('comment thread range lookup', () => { endColumn: markerIndex + ''.length, }) }) + + it('allows a wrap-break before the marker when matching the quote', () => { + const content = 'target\u200B' + + expect( + findCommentThreadRange( + commentThread({ quote: 'target' }), + content, + rangeEditor, + ), + ).toEqual({ + startLineNumber: 0, + startColumn: 'target'.length, + endLineNumber: 0, + endColumn: 0 + 'target'.length, + }) + }) }) describe('comment thread line lookup', () => { diff --git a/app/src/features/document-comments/lib/thread-range.ts b/app/src/features/document-comments/lib/thread-range.ts index e7bb8378..7fe600c4 100644 --- a/app/src/features/document-comments/lib/thread-range.ts +++ b/app/src/features/document-comments/lib/thread-range.ts @@ -2,6 +2,18 @@ import type { DocumentCommentThread } from '@/entities/document' import type { DocumentEditorApi, DocumentEditorRange } from '@/features/plugins' +import { COMMENT_MARKER_WRAP_BREAK } from '../model/comments-store' + +function markerAnchorStart(content: string, markerIndex: number) { + if ( + markerIndex > 0 && + content[markerIndex - 1] === COMMENT_MARKER_WRAP_BREAK + ) { + return markerIndex - 1 + } + return markerIndex +} + export function findCommentMarkerRange( thread: DocumentCommentThread, content: string, @@ -9,7 +21,11 @@ export function findCommentMarkerRange( ): DocumentEditorRange | null { const markerIndex = content.indexOf(thread.marker) if (markerIndex < 0) return null - return editor.getRangeFromOffset(markerIndex, thread.marker.length) + const start = markerAnchorStart(content, markerIndex) + return editor.getRangeFromOffset( + start, + markerIndex + thread.marker.length - start, + ) } export function findCommentThreadRange( @@ -19,14 +35,15 @@ export function findCommentThreadRange( ): DocumentEditorRange | null { const markerIndex = content.indexOf(thread.marker) if (markerIndex >= 0 && thread.quote) { - const quoteStart = Math.max(0, markerIndex - thread.quote.length) - if (content.slice(quoteStart, markerIndex) === thread.quote) { + const quoteEnd = markerAnchorStart(content, markerIndex) + const quoteStart = Math.max(0, quoteEnd - thread.quote.length) + if (content.slice(quoteStart, quoteEnd) === thread.quote) { return editor.getRangeFromOffset(quoteStart, thread.quote.length) } } if (markerIndex >= 0) { - return editor.getRangeFromOffset(markerIndex, thread.marker.length) + return findCommentMarkerRange(thread, content, editor) } if ( diff --git a/app/src/features/document-comments/model/comments-store.ts b/app/src/features/document-comments/model/comments-store.ts index d4f24618..01ae1ef2 100644 --- a/app/src/features/document-comments/model/comments-store.ts +++ b/app/src/features/document-comments/model/comments-store.ts @@ -65,6 +65,13 @@ export function buildCommentMarker(id: string) { return `` } +/** Zero-width space before markers so wrap can break after the quoted word. */ +export const COMMENT_MARKER_WRAP_BREAK = '\u200B' + +export function buildCommentMarkerInsertion(id: string) { + return `${COMMENT_MARKER_WRAP_BREAK}${buildCommentMarker(id)}` +} + export function parseCommentMarkerId(marker: string) { const match = /^$/.exec(marker) return match?.[1] ?? null @@ -100,9 +107,10 @@ export function stripCommentMarkers( seen.add(marker) const escaped = escapeRegExp(marker) // Drop markers that occupy a whole line so we don't leave a blank line. - out = out.replace(new RegExp(`\\n${escaped}(?=\\n|$)`, 'g'), '') - out = out.replace(new RegExp(`^${escaped}\\n`), '') - out = out.replace(new RegExp(`^${escaped}$`), '') + out = out.replace(new RegExp(`\\n\\u200B?${escaped}(?=\\n|$)`, 'g'), '') + out = out.replace(new RegExp(`^\\u200B?${escaped}\\n`), '') + out = out.replace(new RegExp(`^\\u200B?${escaped}$`), '') + out = out.split(`\u200B${marker}`).join('') out = out.split(marker).join('') } return out diff --git a/app/src/features/document-comments/ui/CommentsPanel.tsx b/app/src/features/document-comments/ui/CommentsPanel.tsx index df326dcb..218cdafd 100644 --- a/app/src/features/document-comments/ui/CommentsPanel.tsx +++ b/app/src/features/document-comments/ui/CommentsPanel.tsx @@ -26,6 +26,7 @@ import type { import { findCommentThreadRange } from '../lib/thread-range' import { buildCommentMarker, + buildCommentMarkerInsertion, createCommentId, createCommentMarkerId, getCommentSubmitAction, @@ -377,7 +378,8 @@ export function CommentsPanel({ return } const id = createCommentId() - const marker = buildCommentMarker(createCommentMarkerId()) + const markerId = createCommentMarkerId() + const marker = buildCommentMarker(markerId) const startOffset = editor.getOffsetFromPosition({ lineNumber: selection.startLineNumber, column: selection.startColumn, @@ -389,7 +391,8 @@ export function CommentsPanel({ const inserted = editor.applyEdits([ { range: buildMarkerInsertionRange(selection, editor), - text: marker, + // ZWSP keeps the quote from gluing to the marker for word-wrap. + text: buildCommentMarkerInsertion(markerId), forceMoveMarkers: true, }, ]) @@ -401,7 +404,7 @@ export function CommentsPanel({ ) contentRef.current = [ contentRef.current.slice(0, insertAt), - marker, + buildCommentMarkerInsertion(markerId), contentRef.current.slice(insertAt), ].join('') } @@ -438,9 +441,16 @@ export function CommentsPanel({ window.setTimeout(() => onCommentMetadataChange?.(), 0) } catch (error) { const markerIndex = contentRef.current.indexOf(marker) + const anchorStart = + markerIndex > 0 && contentRef.current[markerIndex - 1] === '\u200B' + ? markerIndex - 1 + : markerIndex const markerRange = markerIndex >= 0 - ? editor.getRangeFromOffset(markerIndex, marker.length) + ? editor.getRangeFromOffset( + anchorStart, + markerIndex + marker.length - anchorStart, + ) : null if (markerRange) { editor.applyEdits([ diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 1dfeb05b..fcfc27b2 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -22,6 +22,7 @@ import { import { buildCommentMarker, + COMMENT_MARKER_WRAP_BREAK, CommentsPanel, createCommentMarkerId, findUnknownCommentMarkers, @@ -97,9 +98,15 @@ const EMPTY_COMMENT_COMPOSER_STATE = { } const MAX_COMPACT_COMMENT_MARKER_ID_LENGTH = 24 -const ADJACENT_COMMENT_MARKER_PATTERN = /^/ +const ADJACENT_COMMENT_MARKER_PATTERN = + /^\u200B?/ const COMMENT_MARKER_PATTERN = //g -const LONE_COMMENT_MARKER_LINE_PATTERN = /\n()(?=\n|$)/g +const LONE_COMMENT_MARKER_LINE_PATTERN = + /\n(\u200B?)(?=\n|$)/g +// Mid-line markers glued to a word force that word onto its own soft-wrapped +// line. Insert a wrap break before those markers. +const GLUED_COMMENT_MARKER_PATTERN = + /([^\s\u200B])()/g function shouldCompactCommentMarker(marker: string) { const markerId = parseCommentMarkerId(marker) @@ -1477,6 +1484,8 @@ export function MarkdownEditor(props: MarkdownEditorProps) { // Markers left alone on a line (common after full-line selections) render as // blank lines once hidden. Pull them onto the previous line. + // Markers glued to a word (what) soft-wrap that word alone; + // insert a ZWSP wrap break before those markers. useEffect(() => { if (readOnly || !documentEditorApi) return const content = getEditorCommentContent() @@ -1485,6 +1494,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { text: string forceMoveMarkers: boolean }> = [] + for (const match of content.matchAll(LONE_COMMENT_MARKER_LINE_PATTERN)) { const marker = match[1] if (typeof match.index !== 'number') continue @@ -1495,14 +1505,32 @@ export function MarkdownEditor(props: MarkdownEditorProps) { if (!range) continue edits.push({ range, text: marker, forceMoveMarkers: true }) } + + for (const match of content.matchAll(GLUED_COMMENT_MARKER_PATTERN)) { + const marker = match[2] + if (typeof match.index !== 'number') continue + const range = documentEditorApi.getRangeFromOffset( + match.index + 1, + marker.length, + ) + if (!range) continue + edits.push({ + range, + text: `${COMMENT_MARKER_WRAP_BREAK}${marker}`, + forceMoveMarkers: true, + }) + } + if (!edits.length) return - documentEditorApi.applyEdits(edits.reverse()) - }, [ - boundText, - documentEditorApi, - getEditorCommentContent, - readOnly, - ]) + documentEditorApi.applyEdits( + [...edits].sort((a, b) => { + if (a.range.startLineNumber !== b.range.startLineNumber) { + return b.range.startLineNumber - a.range.startLineNumber + } + return b.range.startColumn - a.range.startColumn + }), + ) + }, [boundText, documentEditorApi, getEditorCommentContent, readOnly]) useEffect(() => { if (readOnly || !documentEditorApi || !commentThreads.length) return @@ -1536,7 +1564,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { const replaced = documentEditorApi.applyEdits([ { range: markerRange, - text: nextMarker, + text: `${COMMENT_MARKER_WRAP_BREAK}${nextMarker}`, forceMoveMarkers: true, }, ]) @@ -1743,8 +1771,17 @@ export function MarkdownEditor(props: MarkdownEditorProps) { }) for (const match of editorContent.matchAll(COMMENT_MARKER_PATTERN)) { + if (typeof match.index !== 'number') continue + const start = + match.index > 0 && + editorContent[match.index - 1] === COMMENT_MARKER_WRAP_BREAK + ? match.index - 1 + : match.index pushHiddenCommentMarkerDecoration( - editorApi.getRangeFromOffset(match.index, match[0].length), + editorApi.getRangeFromOffset( + start, + match.index + match[0].length - start, + ), ) } diff --git a/app/src/features/edit-document/ui/EditorPane.tsx b/app/src/features/edit-document/ui/EditorPane.tsx index 8ab5758c..2aa9e48a 100644 --- a/app/src/features/edit-document/ui/EditorPane.tsx +++ b/app/src/features/edit-document/ui/EditorPane.tsx @@ -65,9 +65,16 @@ export default function EditorPane({ theme, onBeforeMount, readOnly, onMount, on minimap: { enabled: false }, glyphMargin: true, wordWrap: 'on', - // Comment markers are visually zero-width; without this, wrap still - // reserves their model width and leaves an empty soft-wrapped line. + // Needed so zero-width comment-marker decorations affect wrap width. + // Without this, `word` wraps as one token and the + // commented word sits alone on a soft-wrapped line. + wrappingStrategy: 'advanced', disableMonospaceOptimizations: true, + // Comment anchors insert U+200B so wrap can break after the quote. + // Without this, Monaco draws an orange unicode-highlight box there. + unicodeHighlight: { + allowedCharacters: { '\u200B': true }, + }, scrollBeyondLastLine: true, readOnly, domReadOnly: readOnly, diff --git a/app/src/styles.css b/app/src/styles.css index febdd097..ee65f724 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -581,10 +581,18 @@ .monaco-editor .refmd-comment-anchor-hidden { color: transparent !important; + display: inline-block !important; + width: 0 !important; + max-width: 0 !important; + height: 0 !important; + overflow: hidden !important; font-size: 0 !important; - letter-spacing: 0 !important; line-height: 0 !important; - overflow: hidden !important; + letter-spacing: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: 0 !important; + vertical-align: baseline !important; pointer-events: none !important; } From 344cc30f6476f25a7363c385e514ee002f042d2b Mon Sep 17 00:00:00 2001 From: Aswin Murugesh Date: Tue, 11 Aug 2026 11:59:50 +0530 Subject: [PATCH 4/4] Move comment markers to a new line --- app/src/features/document-comments/index.ts | 1 - .../lib/thread-range.test.ts | 61 +++++++--- .../document-comments/lib/thread-range.ts | 63 +++++++++- .../document-comments/model/comments-store.ts | 5 +- .../document-comments/ui/CommentsPanel.tsx | 112 ++++++++++-------- app/src/features/edit-document/ui/Editor.tsx | 109 ++++++++--------- 6 files changed, 223 insertions(+), 128 deletions(-) diff --git a/app/src/features/document-comments/index.ts b/app/src/features/document-comments/index.ts index 9da52645..3036f6cd 100644 --- a/app/src/features/document-comments/index.ts +++ b/app/src/features/document-comments/index.ts @@ -2,7 +2,6 @@ export { CommentsPanel } from './ui/CommentsPanel' export { buildCommentMarker, buildCommentMarkerInsertion, - COMMENT_MARKER_WRAP_BREAK, createCommentId, createCommentMarkerId, findUnknownCommentMarkers, diff --git a/app/src/features/document-comments/lib/thread-range.test.ts b/app/src/features/document-comments/lib/thread-range.test.ts index b1d9d990..fb2a625b 100644 --- a/app/src/features/document-comments/lib/thread-range.test.ts +++ b/app/src/features/document-comments/lib/thread-range.test.ts @@ -4,7 +4,12 @@ import type { DocumentCommentThread } from '@/entities/document' import type { DocumentEditorApi } from '@/features/plugins' -import { findCommentThreadRange, getCommentThreadLine } from './thread-range' +import { + findCommentThreadRange, + getCommentThreadLine, + getLineEndOffset, + isCommentMarkerAloneOnLine, +} from './thread-range' function commentThread( overrides: Partial = {}, @@ -43,10 +48,7 @@ const rangeEditor = { } as DocumentEditorApi describe('comment thread range lookup', () => { - it('uses the live marker before stale offsets when quote matching fails', () => { - const content = 'changed text' - const markerIndex = content.indexOf('') - + it('uses stored offsets when adjacent quote matching fails', () => { expect( findCommentThreadRange( commentThread({ @@ -54,19 +56,19 @@ describe('comment thread range lookup', () => { startOffset: 0, endOffset: 8, }), - content, + 'changed text', rangeEditor, ), ).toEqual({ - startLineNumber: markerIndex, - startColumn: ''.length, - endLineNumber: markerIndex, - endColumn: markerIndex + ''.length, + startLineNumber: 0, + startColumn: 8, + endLineNumber: 0, + endColumn: 8, }) }) - it('allows a wrap-break before the marker when matching the quote', () => { - const content = 'target\u200B' + it('finds the quote on the previous line when the marker is alone on a line', () => { + const content = 'alpha target beta\n' expect( findCommentThreadRange( @@ -75,10 +77,10 @@ describe('comment thread range lookup', () => { rangeEditor, ), ).toEqual({ - startLineNumber: 0, + startLineNumber: 6, startColumn: 'target'.length, - endLineNumber: 0, - endColumn: 0 + 'target'.length, + endLineNumber: 6, + endColumn: 6 + 'target'.length, }) }) }) @@ -95,9 +97,32 @@ describe('comment thread line lookup', () => { .toBe(3) }) - it('falls back to stored line metadata when the marker is missing', () => { + it('points at the content line when the marker is alone on the next line', () => { + const content = 'target text\n' + + expect(getCommentThreadLine(commentThread(), content)).toBe(1) + }) +}) + +describe('marker line helpers', () => { + it('detects markers alone on a line', () => { + expect( + isCommentMarkerAloneOnLine( + 'hello\n\nworld', + '', + ), + ).toBe(true) expect( - getCommentThreadLine(commentThread({ startLineNumber: 4 }), 'target'), - ).toBe(4) + isCommentMarkerAloneOnLine( + 'hello', + '', + ), + ).toBe(false) + }) + + it('computes line end offsets', () => { + expect(getLineEndOffset('a\nbb\nc', 1)).toBe(1) + expect(getLineEndOffset('a\nbb\nc', 2)).toBe(4) + expect(getLineEndOffset('a\nbb\nc', 3)).toBe(6) }) }) diff --git a/app/src/features/document-comments/lib/thread-range.ts b/app/src/features/document-comments/lib/thread-range.ts index 7fe600c4..e325ccc7 100644 --- a/app/src/features/document-comments/lib/thread-range.ts +++ b/app/src/features/document-comments/lib/thread-range.ts @@ -4,6 +4,8 @@ import type { DocumentEditorApi, DocumentEditorRange } from '@/features/plugins' import { COMMENT_MARKER_WRAP_BREAK } from '../model/comments-store' +const LONE_MARKER_LINE = /^\u200B?$/ + function markerAnchorStart(content: string, markerIndex: number) { if ( markerIndex > 0 && @@ -14,6 +16,19 @@ function markerAnchorStart(content: string, markerIndex: number) { return markerIndex } +/** True when the marker is the only content on its line (optional leading ZWSP). */ +export function isCommentMarkerAloneOnLine( + content: string, + marker: string, + markerIndex = content.indexOf(marker), +) { + if (markerIndex < 0) return false + const lineStart = content.lastIndexOf('\n', markerIndex - 1) + 1 + const nextNl = content.indexOf('\n', markerIndex) + const lineEnd = nextNl < 0 ? content.length : nextNl + return LONE_MARKER_LINE.test(content.slice(lineStart, lineEnd)) +} + export function findCommentMarkerRange( thread: DocumentCommentThread, content: string, @@ -40,9 +55,35 @@ export function findCommentThreadRange( if (content.slice(quoteStart, quoteEnd) === thread.quote) { return editor.getRangeFromOffset(quoteStart, thread.quote.length) } + + // Markers live on their own hidden line after the content line. Find the + // quote on the previous line so highlights stay on the real text. + if (isCommentMarkerAloneOnLine(content, thread.marker, markerIndex)) { + const markerLineStart = content.lastIndexOf('\n', markerIndex - 1) + 1 + const prevLineEnd = Math.max(0, markerLineStart - 1) + const prevLineStart = + prevLineEnd > 0 ? content.lastIndexOf('\n', prevLineEnd - 1) + 1 : 0 + const prevLine = content.slice(prevLineStart, prevLineEnd) + const quotePos = prevLine.lastIndexOf(thread.quote) + if (quotePos >= 0) { + return editor.getRangeFromOffset( + prevLineStart + quotePos, + thread.quote.length, + ) + } + } } if (markerIndex >= 0) { + // Prefer stored offsets over highlighting the hidden marker itself. + if ( + typeof thread.startOffset === 'number' && + typeof thread.endOffset === 'number' && + thread.endOffset > thread.startOffset + ) { + const length = thread.endOffset - thread.startOffset + return editor.getRangeFromOffset(thread.startOffset, length) + } return findCommentMarkerRange(thread, content, editor) } @@ -73,7 +114,11 @@ export function getCommentThreadLine( ) { const markerIndex = content.indexOf(thread.marker) if (markerIndex >= 0) { - return content.slice(0, markerIndex).split('\n').length + const markerLine = content.slice(0, markerIndex).split('\n').length + if (isCommentMarkerAloneOnLine(content, thread.marker, markerIndex)) { + return Math.max(1, markerLine - 1) + } + return markerLine } if (thread.startLineNumber && Number.isFinite(thread.startLineNumber)) { @@ -82,3 +127,19 @@ export function getCommentThreadLine( return null } + +/** 1-based line number → offset of that line's trailing newline, or content.length at EOF. */ +export function getLineEndOffset(content: string, lineNumber: number) { + const target = Math.max(1, Math.floor(lineNumber)) + let line = 1 + let index = 0 + while (line < target && index < content.length) { + const nl = content.indexOf('\n', index) + if (nl < 0) return content.length + index = nl + 1 + line += 1 + } + if (index >= content.length) return content.length + const nl = content.indexOf('\n', index) + return nl < 0 ? content.length : nl +} diff --git a/app/src/features/document-comments/model/comments-store.ts b/app/src/features/document-comments/model/comments-store.ts index 01ae1ef2..338453f9 100644 --- a/app/src/features/document-comments/model/comments-store.ts +++ b/app/src/features/document-comments/model/comments-store.ts @@ -65,11 +65,12 @@ export function buildCommentMarker(id: string) { return `` } -/** Zero-width space before markers so wrap can break after the quoted word. */ +/** Zero-width space kept for legacy inline markers during migration. */ export const COMMENT_MARKER_WRAP_BREAK = '\u200B' export function buildCommentMarkerInsertion(id: string) { - return `${COMMENT_MARKER_WRAP_BREAK}${buildCommentMarker(id)}` + // Markers sit on their own line so they don't steal wrap width from content. + return `\n${buildCommentMarker(id)}` } export function parseCommentMarkerId(marker: string) { diff --git a/app/src/features/document-comments/ui/CommentsPanel.tsx b/app/src/features/document-comments/ui/CommentsPanel.tsx index 218cdafd..189c0ae5 100644 --- a/app/src/features/document-comments/ui/CommentsPanel.tsx +++ b/app/src/features/document-comments/ui/CommentsPanel.tsx @@ -23,7 +23,10 @@ import type { DocumentEditorSelection, } from '@/features/plugins' -import { findCommentThreadRange } from '../lib/thread-range' +import { + findCommentThreadRange, + getLineEndOffset, +} from '../lib/thread-range' import { buildCommentMarker, buildCommentMarkerInsertion, @@ -127,36 +130,28 @@ function threadMatchesSearch( return haystack.includes(query) } -function validateTags(tags: string[]) { - return tags.every((tag) => tag.length <= 64) -} - -function buildMarkerInsertionRange( +function buildMarkerLineInsertion( + content: string, selection: DocumentEditorSelection, editor: DocumentEditorApi, -): DocumentEditorRange { - // Monaco full-line selections usually end at column 1 of the next line - // (they include the trailing newline). Inserting there parks the marker on - // that following line alone, which looks like a blank line once hidden. + markerText: string, +): { range: DocumentEditorRange; text: string } | null { + let lineNumber = selection.endLineNumber + // Full-line selections end at column 1 of the next line. if ( selection.endColumn === 1 && selection.endLineNumber > selection.startLineNumber ) { - const endOffset = editor.getOffsetFromPosition({ - lineNumber: selection.endLineNumber, - column: 1, - }) - if (typeof endOffset === 'number' && endOffset > 0) { - const range = editor.getRangeFromOffset(endOffset - 1, 0) - if (range) return range - } - } - return { - startLineNumber: selection.endLineNumber, - startColumn: selection.endColumn, - endLineNumber: selection.endLineNumber, - endColumn: selection.endColumn, + lineNumber = selection.endLineNumber - 1 } + const lineEndOffset = getLineEndOffset(content, lineNumber) + const range = editor.getRangeFromOffset(lineEndOffset, 0) + if (!range) return null + return { range, text: markerText } +} + +function validateTags(tags: string[]) { + return tags.every((tag) => tag.length <= 64) } export function CommentsPanel({ @@ -380,6 +375,7 @@ export function CommentsPanel({ const id = createCommentId() const markerId = createCommentMarkerId() const marker = buildCommentMarker(markerId) + const markerText = buildCommentMarkerInsertion(markerId) const startOffset = editor.getOffsetFromPosition({ lineNumber: selection.startLineNumber, column: selection.startColumn, @@ -388,23 +384,31 @@ export function CommentsPanel({ lineNumber: selection.endLineNumber, column: selection.endColumn, }) + const insertion = buildMarkerLineInsertion( + contentRef.current, + selection, + editor, + markerText, + ) + if (!insertion) return const inserted = editor.applyEdits([ { - range: buildMarkerInsertionRange(selection, editor), - // ZWSP keeps the quote from gluing to the marker for word-wrap. - text: buildCommentMarkerInsertion(markerId), + range: insertion.range, + text: insertion.text, forceMoveMarkers: true, }, ]) if (!inserted) return - if (typeof endOffset === 'number' && !contentRef.current.includes(marker)) { - const insertAt = Math.max( - 0, - Math.min(contentRef.current.length, endOffset), - ) + if (!contentRef.current.includes(marker)) { + const lineNumber = + selection.endColumn === 1 && + selection.endLineNumber > selection.startLineNumber + ? selection.endLineNumber - 1 + : selection.endLineNumber + const insertAt = getLineEndOffset(contentRef.current, lineNumber) contentRef.current = [ contentRef.current.slice(0, insertAt), - buildCommentMarkerInsertion(markerId), + markerText, contentRef.current.slice(insertAt), ].join('') } @@ -441,25 +445,29 @@ export function CommentsPanel({ window.setTimeout(() => onCommentMetadataChange?.(), 0) } catch (error) { const markerIndex = contentRef.current.indexOf(marker) - const anchorStart = - markerIndex > 0 && contentRef.current[markerIndex - 1] === '\u200B' - ? markerIndex - 1 - : markerIndex - const markerRange = - markerIndex >= 0 - ? editor.getRangeFromOffset( - anchorStart, - markerIndex + marker.length - anchorStart, - ) - : null - if (markerRange) { - editor.applyEdits([ - { - range: markerRange, - text: '', - forceMoveMarkers: true, - }, - ]) + if (markerIndex >= 0) { + let start = markerIndex + let length = marker.length + if (markerIndex > 0 && contentRef.current[markerIndex - 1] === '\n') { + start = markerIndex - 1 + length += 1 + } else if ( + markerIndex > 0 && + contentRef.current[markerIndex - 1] === '\u200B' + ) { + start = markerIndex - 1 + length += 1 + } + const markerRange = editor.getRangeFromOffset(start, length) + if (markerRange) { + editor.applyEdits([ + { + range: markerRange, + text: '', + forceMoveMarkers: true, + }, + ]) + } } toast.error('Could not save comment') } finally { diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index fcfc27b2..87ef02ef 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -22,7 +22,6 @@ import { import { buildCommentMarker, - COMMENT_MARKER_WRAP_BREAK, CommentsPanel, createCommentMarkerId, findUnknownCommentMarkers, @@ -33,6 +32,7 @@ import { findCommentMarkerRange, findCommentThreadRange, getCommentThreadLine, + getLineEndOffset, } from '@/features/document-comments/lib/thread-range' import { useAwarenessStyles } from '@/features/edit-document/hooks/useAwarenessStyles' import { markDocumentContentDirty } from '@/features/edit-document/hooks/useCollaborativeDocument' @@ -101,12 +101,9 @@ const MAX_COMPACT_COMMENT_MARKER_ID_LENGTH = 24 const ADJACENT_COMMENT_MARKER_PATTERN = /^\u200B?/ const COMMENT_MARKER_PATTERN = //g -const LONE_COMMENT_MARKER_LINE_PATTERN = - /\n(\u200B?)(?=\n|$)/g -// Mid-line markers glued to a word force that word onto its own soft-wrapped -// line. Insert a wrap break before those markers. -const GLUED_COMMENT_MARKER_PATTERN = - /([^\s\u200B])()/g +const INLINE_COMMENT_MARKER_PATTERN = + /([^\n])(\u200B?)/g +const LONE_MARKER_LINE_PATTERN = /^\u200B?$/ function shouldCompactCommentMarker(marker: string) { const markerId = parseCommentMarkerId(marker) @@ -1482,56 +1479,61 @@ export function MarkdownEditor(props: MarkdownEditorProps) { } }, [editorMountNonce, editorRef, emitReadOnlyWarning, readOnly]) - // Markers left alone on a line (common after full-line selections) render as - // blank lines once hidden. Pull them onto the previous line. - // Markers glued to a word (what) soft-wrap that word alone; - // insert a ZWSP wrap break before those markers. + // Inline markers steal wrap width (they're real model characters). Move them + // onto their own line after the content line, then hide those marker lines. useEffect(() => { if (readOnly || !documentEditorApi) return const content = getEditorCommentContent() - const edits: Array<{ - range: DocumentEditorRange - text: string - forceMoveMarkers: boolean - }> = [] - - for (const match of content.matchAll(LONE_COMMENT_MARKER_LINE_PATTERN)) { - const marker = match[1] - if (typeof match.index !== 'number') continue - const range = documentEditorApi.getRangeFromOffset( - match.index, - 1 + marker.length, - ) - if (!range) continue - edits.push({ range, text: marker, forceMoveMarkers: true }) - } - - for (const match of content.matchAll(GLUED_COMMENT_MARKER_PATTERN)) { - const marker = match[2] - if (typeof match.index !== 'number') continue - const range = documentEditorApi.getRangeFromOffset( - match.index + 1, - marker.length, - ) - if (!range) continue - edits.push({ - range, - text: `${COMMENT_MARKER_WRAP_BREAK}${marker}`, - forceMoveMarkers: true, - }) - } - - if (!edits.length) return - documentEditorApi.applyEdits( - [...edits].sort((a, b) => { - if (a.range.startLineNumber !== b.range.startLineNumber) { - return b.range.startLineNumber - a.range.startLineNumber - } - return b.range.startColumn - a.range.startColumn - }), + INLINE_COMMENT_MARKER_PATTERN.lastIndex = 0 + const match = INLINE_COMMENT_MARKER_PATTERN.exec(content) + if (!match || typeof match.index !== 'number') return + + const markerWithBreak = match[2] + const marker = markerWithBreak.replace(/^\u200B/, '') + const markerStart = match.index + 1 + const removeRange = documentEditorApi.getRangeFromOffset( + markerStart, + markerWithBreak.length, ) + if (!removeRange) return + + documentEditorApi.applyEdits([ + { range: removeRange, text: '', forceMoveMarkers: true }, + ]) + const nextContent = getEditorCommentContent() + const lineEndOffset = getLineEndOffset( + nextContent, + removeRange.startLineNumber, + ) + const insertRange = documentEditorApi.getRangeFromOffset(lineEndOffset, 0) + if (!insertRange) return + documentEditorApi.applyEdits([ + { range: insertRange, text: `\n${marker}`, forceMoveMarkers: true }, + ]) }, [boundText, documentEditorApi, getEditorCommentContent, readOnly]) + // Hide marker-only lines so they don't affect wrapping or show as blanks. + useEffect(() => { + if (!documentEditorApi) return + const content = getEditorCommentContent() + const lines = content.split('\n') + const hidden = lines.flatMap((line, index) => { + if (!LONE_MARKER_LINE_PATTERN.test(line)) return [] + const lineNumber = index + 1 + return [ + { + range: { + startLineNumber: lineNumber, + startColumn: 1, + endLineNumber: lineNumber, + endColumn: Math.max(1, line.length + 1), + }, + }, + ] + }) + return documentEditorApi.setHiddenRanges('core-comment-markers', hidden) + }, [boundText, documentEditorApi, getEditorCommentContent]) + useEffect(() => { if (readOnly || !documentEditorApi || !commentThreads.length) return @@ -1564,7 +1566,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { const replaced = documentEditorApi.applyEdits([ { range: markerRange, - text: `${COMMENT_MARKER_WRAP_BREAK}${nextMarker}`, + text: nextMarker, forceMoveMarkers: true, }, ]) @@ -1773,8 +1775,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { for (const match of editorContent.matchAll(COMMENT_MARKER_PATTERN)) { if (typeof match.index !== 'number') continue const start = - match.index > 0 && - editorContent[match.index - 1] === COMMENT_MARKER_WRAP_BREAK + match.index > 0 && editorContent[match.index - 1] === '\u200B' ? match.index - 1 : match.index pushHiddenCommentMarkerDecoration(