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/document-comments/index.ts b/app/src/features/document-comments/index.ts index e2a3ec45..3036f6cd 100644 --- a/app/src/features/document-comments/index.ts +++ b/app/src/features/document-comments/index.ts @@ -1,6 +1,7 @@ export { CommentsPanel } from './ui/CommentsPanel' export { buildCommentMarker, + buildCommentMarkerInsertion, 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..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,14 +56,31 @@ describe('comment thread range lookup', () => { startOffset: 0, endOffset: 8, }), + 'changed text', + rangeEditor, + ), + ).toEqual({ + startLineNumber: 0, + startColumn: 8, + endLineNumber: 0, + endColumn: 8, + }) + }) + + it('finds the quote on the previous line when the marker is alone on a line', () => { + const content = 'alpha target beta\n' + + expect( + findCommentThreadRange( + commentThread({ quote: 'target' }), content, rangeEditor, ), ).toEqual({ - startLineNumber: markerIndex, - startColumn: ''.length, - endLineNumber: markerIndex, - endColumn: markerIndex + ''.length, + startLineNumber: 6, + startColumn: 'target'.length, + endLineNumber: 6, + endColumn: 6 + 'target'.length, }) }) }) @@ -78,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( - getCommentThreadLine(commentThread({ startLineNumber: 4 }), 'target'), - ).toBe(4) + isCommentMarkerAloneOnLine( + 'hello\n\nworld', + '', + ), + ).toBe(true) + expect( + 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 e7bb8378..e325ccc7 100644 --- a/app/src/features/document-comments/lib/thread-range.ts +++ b/app/src/features/document-comments/lib/thread-range.ts @@ -2,6 +2,33 @@ import type { DocumentCommentThread } from '@/entities/document' 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 && + content[markerIndex - 1] === COMMENT_MARKER_WRAP_BREAK + ) { + return markerIndex - 1 + } + 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, @@ -9,7 +36,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 +50,41 @@ 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) } + + // 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) { - return editor.getRangeFromOffset(markerIndex, thread.marker.length) + // 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) } if ( @@ -56,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)) { @@ -65,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.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..338453f9 100644 --- a/app/src/features/document-comments/model/comments-store.ts +++ b/app/src/features/document-comments/model/comments-store.ts @@ -65,6 +65,14 @@ export function buildCommentMarker(id: string) { return `` } +/** Zero-width space kept for legacy inline markers during migration. */ +export const COMMENT_MARKER_WRAP_BREAK = '\u200B' + +export function buildCommentMarkerInsertion(id: string) { + // Markers sit on their own line so they don't steal wrap width from content. + return `\n${buildCommentMarker(id)}` +} + export function parseCommentMarkerId(marker: string) { const match = /^$/.exec(marker) return match?.[1] ?? null @@ -72,6 +80,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 +106,12 @@ 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\\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 5d29533e..189c0ae5 100644 --- a/app/src/features/document-comments/ui/CommentsPanel.tsx +++ b/app/src/features/document-comments/ui/CommentsPanel.tsx @@ -23,9 +23,13 @@ import type { DocumentEditorSelection, } from '@/features/plugins' -import { findCommentThreadRange } from '../lib/thread-range' +import { + findCommentThreadRange, + getLineEndOffset, +} from '../lib/thread-range' import { buildCommentMarker, + buildCommentMarkerInsertion, createCommentId, createCommentMarkerId, getCommentSubmitAction, @@ -126,19 +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, -): DocumentEditorRange { - return { - startLineNumber: selection.endLineNumber, - startColumn: selection.endColumn, - endLineNumber: selection.endLineNumber, - endColumn: selection.endColumn, + editor: DocumentEditorApi, + 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 + ) { + 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({ @@ -360,7 +373,9 @@ export function CommentsPanel({ return } const id = createCommentId() - const marker = buildCommentMarker(createCommentMarkerId()) + const markerId = createCommentMarkerId() + const marker = buildCommentMarker(markerId) + const markerText = buildCommentMarkerInsertion(markerId) const startOffset = editor.getOffsetFromPosition({ lineNumber: selection.startLineNumber, column: selection.startColumn, @@ -369,22 +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), - text: marker, + 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), - marker, + markerText, contentRef.current.slice(insertAt), ].join('') } @@ -421,18 +445,29 @@ export function CommentsPanel({ window.setTimeout(() => onCommentMetadataChange?.(), 0) } catch (error) { const markerIndex = contentRef.current.indexOf(marker) - const markerRange = - markerIndex >= 0 - ? editor.getRangeFromOffset(markerIndex, marker.length) - : 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/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..87ef02ef 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -32,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' @@ -97,8 +98,12 @@ 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 INLINE_COMMENT_MARKER_PATTERN = + /([^\n])(\u200B?)/g +const LONE_MARKER_LINE_PATTERN = /^\u200B?$/ function shouldCompactCommentMarker(marker: string) { const markerId = parseCommentMarkerId(marker) @@ -143,7 +148,6 @@ type RefmdEditorInstance = monacoNs.editor.IStandaloneCodeEditor & { __disposeCursor?: () => void __disposeMonacoMd?: () => void __disposeKeydown?: () => void - __disposeDirtyTracker?: () => void __readOnlyOverlay?: { widget: monacoNs.editor.IOverlayWidget domNode: HTMLElement @@ -355,6 +359,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 +546,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 +779,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 +826,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 +918,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { setReadOnlyOverlay, enableVimMode, brandedMonacoTheme, - documentId, ], ) @@ -1283,12 +1276,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 } @@ -1492,6 +1479,61 @@ export function MarkdownEditor(props: MarkdownEditorProps) { } }, [editorMountNonce, editorRef, emitReadOnlyWarning, readOnly]) + // 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() + 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 @@ -1731,8 +1773,16 @@ 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] === '\u200B' + ? 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 0269ab82..2aa9e48a 100644 --- a/app/src/features/edit-document/ui/EditorPane.tsx +++ b/app/src/features/edit-document/ui/EditorPane.tsx @@ -65,6 +65,16 @@ export default function EditorPane({ theme, onBeforeMount, readOnly, onMount, on minimap: { enabled: false }, glyphMargin: true, wordWrap: 'on', + // 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 4ecc5b18..ee65f724 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -581,9 +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; + letter-spacing: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: 0 !important; + vertical-align: baseline !important; pointer-events: none !important; }