Skip to content

Commit 42b895e

Browse files
fix: comment bubble got cut off (#2756)
* fix: comment bubble got cut off * fix: update floating comments remeasure logic * fix: make test pass * fix: address review comments * test: add regression test for comment bubble clipping (SD-2464) Behavior test that creates 8+ tracked changes, clicks the last bubble, and asserts the sidebar uses overflow: visible so bubbles are never clipped by the parent container. --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 33c2c36 commit 42b895e

5 files changed

Lines changed: 115 additions & 19 deletions

File tree

packages/superdoc/src/SuperDoc.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,8 +1642,7 @@ const getPDFViewer = () => {
16421642
min-width: 300px;
16431643
width: 300px;
16441644
height: 100%;
1645-
overflow-y: hidden;
1646-
overflow-x: hidden;
1645+
overflow: visible;
16471646
}
16481647

16491648
.superdoc__layers {

packages/superdoc/src/components/CommentsLayer/FloatingComments.vue

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,13 @@ const totalHeight = computed(() => {
205205
return max + 50;
206206
});
207207
208+
// The inner sidebar is translated by sidebarOffsetY. When shifted down, the
209+
// rendered bottom edge can exceed totalHeight and get clipped by parent scroll
210+
// containers. Expand wrapper height to include positive translate offset.
211+
const wrapperMinHeight = computed(() => {
212+
return totalHeight.value + Math.max(0, sidebarOffsetY.value);
213+
});
214+
208215
// Set up IntersectionObserver to track which placeholders are near the viewport
209216
const setupObserver = () => {
210217
if (observer) observer.disconnect();
@@ -287,6 +294,17 @@ const handleResize = (comment) => {
287294
const dialog = el.querySelector('.comments-dialog');
288295
if (!dialog) return;
289296
storeHeight(key, dialog.getBoundingClientRect().height);
297+
298+
const isActiveThread = key === activeCommentKey.value;
299+
const isPending = key === 'pending';
300+
const isEditingThread = !!editingCommentId.value && editingCommentId.value === comment?.commentId;
301+
if (!isActiveThread && !isPending && !isEditingThread) return;
302+
303+
// Reflow nearby cards after size changes of the active/pending/editing thread.
304+
// Avoid force-snapping to anchor here because it can over-shift the whole lane
305+
// near viewport boundaries and make bottom clipping more frequent.
306+
remeasureCommentKeys(allPositions.value.map((pos) => pos.id));
307+
scheduleDeferredRemeasure(() => allPositions.value.map((pos) => pos.id));
290308
});
291309
};
292310
@@ -355,6 +373,25 @@ const remeasureCommentKeys = (keys) => {
355373
}
356374
};
357375
376+
// 50ms: after Vue nextTick + browser rAF settle the initial DOM change.
377+
// 350ms: after .comment-placeholder transition (300ms ease) completes.
378+
const REMEASURE_AFTER_DOM_SETTLE_MS = 50;
379+
const REMEASURE_AFTER_PLACEHOLDER_TRANSITION_MS = 350;
380+
381+
/**
382+
* Cancels any pending delayed remeasure passes, then schedules two remeasure runs.
383+
* Pass an array of keys, or a getter so keys are resolved when each timeout fires
384+
* (e.g. when `allPositions` may have changed).
385+
*/
386+
const scheduleDeferredRemeasure = (keysOrGetter) => {
387+
clearDeferredRemeasureTimers();
388+
const resolveKeys = typeof keysOrGetter === 'function' ? keysOrGetter : () => keysOrGetter;
389+
remeasureTimers.push(setTimeout(() => remeasureCommentKeys(resolveKeys()), REMEASURE_AFTER_DOM_SETTLE_MS));
390+
remeasureTimers.push(
391+
setTimeout(() => remeasureCommentKeys(resolveKeys()), REMEASURE_AFTER_PLACEHOLDER_TRANSITION_MS),
392+
);
393+
};
394+
358395
const finishInstantSidebarAlignment = () => {
359396
clearInstantSidebarAlignment();
360397
requestAnimationFrame(() => {
@@ -382,16 +419,13 @@ watch(activeCommentKey, (newKey, oldKey) => {
382419
const hasPendingInstantAlignment =
383420
newKey && newKey === instantAlignmentKey.value && Number.isFinite(instantSidebarAlignmentTargetY.value);
384421
385-
// 50ms: after Vue nextTick + browser rAF settle the initial DOM change
386-
// 350ms: after .comment-placeholder transition (300ms ease) completes
387422
nextTick(() => {
388423
if (hasPendingInstantAlignment) {
389424
remeasureCommentKeys(keysToRemeasure);
390425
return;
391426
}
392427
393-
remeasureTimers.push(setTimeout(() => remeasureCommentKeys(keysToRemeasure), 50));
394-
remeasureTimers.push(setTimeout(() => remeasureCommentKeys(keysToRemeasure), 350));
428+
scheduleDeferredRemeasure(keysToRemeasure);
395429
});
396430
});
397431
@@ -408,8 +442,7 @@ watch(editingCommentId, () => {
408442
clearDeferredRemeasureTimers();
409443
410444
nextTick(() => {
411-
remeasureTimers.push(setTimeout(() => remeasureCommentKeys(allPositions.value.map((pos) => pos.id)), 50));
412-
remeasureTimers.push(setTimeout(() => remeasureCommentKeys(allPositions.value.map((pos) => pos.id)), 350));
445+
scheduleDeferredRemeasure(() => allPositions.value.map((pos) => pos.id));
413446
});
414447
});
415448
@@ -567,7 +600,7 @@ onBeforeUnmount(() => {
567600
class="section-wrapper"
568601
ref="floatingCommentsContainer"
569602
:style="{
570-
minHeight: totalHeight + 'px',
603+
minHeight: wrapperMinHeight + 'px',
571604
transition: disableInstantLayoutTransitions ? 'none' : undefined,
572605
}"
573606
>

packages/superdoc/src/components/CommentsLayer/commentsList/commentsList.vue

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
<script setup>
22
import { storeToRefs } from 'pinia';
3-
import { computed, onBeforeUnmount, onMounted, reactive, getCurrentInstance } from 'vue';
3+
import { computed, onBeforeUnmount, onMounted } from 'vue';
44
import { useCommentsStore } from '@stores/comments-store';
5-
import { useSuperdocStore } from '@stores/superdoc-store';
65
import CommentDialog from '../CommentDialog.vue';
76
87
const props = defineProps({
@@ -16,11 +15,8 @@ const props = defineProps({
1615
},
1716
});
1817
19-
const superdocStore = useSuperdocStore();
2018
const commentsStore = useCommentsStore();
21-
const { COMMENT_EVENTS } = commentsStore;
22-
const { commentsList, getGroupedComments, isCommentsListVisible } = storeToRefs(commentsStore);
23-
const { proxy } = getCurrentInstance();
19+
const { getGroupedComments, isCommentsListVisible } = storeToRefs(commentsStore);
2420
2521
const shouldShowResolvedComments = computed(() => {
2622
return props.showResolvedComments && getGroupedComments.value?.resolvedComments?.length > 0;
@@ -38,14 +34,14 @@ onBeforeUnmount(() => {
3834
<template>
3935
<div class="comments-list">
4036
<div v-if="showMainComments">
41-
<div v-for="comment in getGroupedComments.parentComments" class="comment-item">
37+
<div v-for="comment in getGroupedComments.parentComments" :key="comment.commentId" class="comment-item">
4238
<CommentDialog :comment="comment" />
4339
</div>
4440
</div>
4541
4642
<div v-if="shouldShowResolvedComments">
4743
<div class="comment-title">Resolved</div>
48-
<div v-for="comment in getGroupedComments.resolvedComments" class="comment-item">
44+
<div v-for="comment in getGroupedComments.resolvedComments" :key="comment.commentId" class="comment-item">
4945
<CommentDialog :comment="comment" />
5046
</div>
5147
</div>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { test, expect } from '../../fixtures/superdoc.js';
2+
import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js';
3+
4+
test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } });
5+
6+
test('@behavior SD-2464: last comment bubble is not clipped when many tracked changes exist', async ({ superdoc }) => {
7+
await assertDocumentApiReady(superdoc.page);
8+
9+
// Switch to suggesting mode so edits create tracked changes
10+
await superdoc.setDocumentMode('suggesting');
11+
await superdoc.waitForStable();
12+
13+
// Create 8 tracked changes on separate lines to push the last bubble low
14+
for (let i = 0; i < 8; i++) {
15+
await superdoc.type(`tracked change ${i + 1}`);
16+
await superdoc.newLine();
17+
await superdoc.waitForStable();
18+
}
19+
20+
// Verify tracked changes were created
21+
await expect
22+
.poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total)
23+
.toBeGreaterThanOrEqual(8);
24+
25+
// Wait for floating comment placeholders to render
26+
const placeholders = superdoc.page.locator('.comment-placeholder');
27+
await expect(placeholders.first()).toBeAttached({ timeout: 10_000 });
28+
29+
// Click the last bubble to activate it (triggers sidebar alignment)
30+
const lastDialog = placeholders.last().locator('.comments-dialog');
31+
await expect(lastDialog).toBeAttached({ timeout: 10_000 });
32+
await lastDialog.click({ position: { x: 12, y: 12 } });
33+
await superdoc.waitForStable();
34+
35+
// Wait for the alignment timer (400ms) + transition (300ms) + buffer
36+
await superdoc.page.waitForTimeout(1000);
37+
38+
// The active dialog should be fully visible — not clipped by the parent container.
39+
// Get the bounding rects of the active dialog and the .floating-comments container.
40+
const clipping = await superdoc.page.evaluate(() => {
41+
const activeDialog = document.querySelector('.comments-dialog.is-active');
42+
const floatingComments = document.querySelector('.floating-comments');
43+
if (!activeDialog || !floatingComments) return null;
44+
45+
const dRect = activeDialog.getBoundingClientRect();
46+
const fRect = floatingComments.getBoundingClientRect();
47+
48+
return {
49+
dialogBottom: dRect.bottom,
50+
containerBottom: fRect.bottom,
51+
containerOverflow: getComputedStyle(floatingComments).overflow,
52+
};
53+
});
54+
55+
expect(clipping).not.toBeNull();
56+
57+
// The container should use overflow: visible (not hidden) so bubbles are never clipped
58+
expect(clipping!.containerOverflow).toBe('visible');
59+
60+
// The dialog should either fit within the container OR overflow is visible so it's still shown
61+
// With overflow: visible, even if dialogBottom > containerBottom the content is still visible
62+
// This test primarily guards against regression to overflow: hidden
63+
});

tests/behavior/tests/formatting/linked-style-partial-selection.spec.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,15 @@ test.describe('SD-2425 linked style partial selection', () => {
129129
await applyLinkedStyleToSelection(superdoc.page, 'Heading1');
130130
await superdoc.waitForStable();
131131

132-
// Move cursor to end and press Enter
133-
await superdoc.press('End');
132+
// Place a collapsed cursor at paragraph end via document positions.
133+
// Using key events (End) can be flaky in CI depending on focus timing.
134+
const worldPos = await superdoc.findTextPos('world');
135+
const paragraphEnd = worldPos + 'world'.length;
136+
await superdoc.setTextSelection(paragraphEnd, paragraphEnd);
134137
await superdoc.waitForStable();
138+
135139
await superdoc.newLine();
140+
await superdoc.waitForStable();
136141
await superdoc.type('new text');
137142
await superdoc.waitForStable();
138143

0 commit comments

Comments
 (0)