diff --git a/apps/docs/editor/built-in-ui/comments.mdx b/apps/docs/editor/built-in-ui/comments.mdx
index d9be76cc06..1e332f903e 100644
--- a/apps/docs/editor/built-in-ui/comments.mdx
+++ b/apps/docs/editor/built-in-ui/comments.mdx
@@ -142,6 +142,44 @@ modules: {
Colors for the active tracked change highlight. Same properties as `trackChangeHighlightColors`. Defaults to `trackChangeHighlightColors` values when not set.
+## Small-screen behavior
+
+Use `displayMode` to control how comment/tracked-change bubbles render when container width is constrained.
+
+
+ Display policy for comment and tracked-change bubbles.
+ - `'sidebar'`: always render right sidebar bubbles.
+ - `'inline'`: always render compact inline popover mode.
+ - `'auto'`: switch between sidebar and inline mode based on measured available width.
+
+
+
+ Optional fixed compact threshold (px) for `displayMode: 'auto'`.
+ If set, inline mode activates when available width is below this value.
+
+
+
+ Optional CSS selector for the width measurement target in `displayMode: 'auto'`.
+ Useful for custom flex/grid host shells where the default measurement target is not representative.
+
+
+```javascript
+modules: {
+ comments: {
+ displayMode: "auto",
+ },
+}
+```
+
+```javascript
+modules: {
+ comments: {
+ displayMode: "auto",
+ compactMeasurementSelector: "#editor-shell",
+ },
+}
+```
+
## Viewing mode visibility
Comments are hidden by default when `documentMode` is `viewing`. Use
diff --git a/apps/docs/editor/superdoc/configuration.mdx b/apps/docs/editor/superdoc/configuration.mdx
index 1343a1c4cb..d0dfdfb729 100644
--- a/apps/docs/editor/superdoc/configuration.mdx
+++ b/apps/docs/editor/superdoc/configuration.mdx
@@ -184,6 +184,20 @@ new SuperDoc({
DOM element for comments list
+
+ Comments/track-changes bubble display policy.
+ - `'sidebar'`: always use the right sidebar.
+ - `'inline'`: always use compact inline popover mode.
+ - `'auto'`: switch between sidebar and compact inline mode based on available container width.
+
+
+ Optional fixed compact-mode threshold override in pixels (used when `displayMode: 'auto'`).
+ When set, compact mode is active when measured available width is below this value.
+
+
+ Optional CSS selector for the element used to measure available width in `displayMode: 'auto'`.
+ Use this when SuperDoc is embedded in custom flex/grid shells and the default fallback target is not representative.
+
Allow resolving comments
diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js
index 3526a114dc..f269e11ccc 100644
--- a/packages/superdoc/src/SuperDoc.test.js
+++ b/packages/superdoc/src/SuperDoc.test.js
@@ -1864,6 +1864,261 @@ describe('SuperDoc.vue', () => {
expect(wrapper.find('.floating-comments').exists()).toBe(true);
});
+ it('hides sidebar when comments displayMode is inline', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ commentsStoreStub.getFloatingComments.value = [{ commentId: 'c-1' }];
+ commentsStoreStub.hasInitializedLocations.value = true;
+ superdocStoreStub.isReady.value = true;
+ superdocStoreStub.modules.comments.displayMode = 'inline';
+ wrapper.vm.recalculateCompactCommentsMode();
+ await nextTick();
+
+ expect(wrapper.vm.showCommentsSidebar).toBe(false);
+ expect(wrapper.find('.superdoc__right-sidebar').exists()).toBe(false);
+ });
+
+ it('hides sidebar in auto mode when compact threshold is reached', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ commentsStoreStub.getFloatingComments.value = [{ commentId: 'c-1' }];
+ commentsStoreStub.hasInitializedLocations.value = true;
+ superdocStoreStub.isReady.value = true;
+ superdocStoreStub.modules.comments.displayMode = 'auto';
+ superdocStoreStub.modules.comments.compactBreakpointPx = 760;
+
+ const rootEl = wrapper.find('.superdoc').element;
+ const parentEl = rootEl.parentElement;
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 700 });
+ if (parentEl) {
+ Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 700 });
+ }
+
+ wrapper.vm.recalculateCompactCommentsMode();
+ await nextTick();
+
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+ expect(wrapper.vm.showCommentsSidebar).toBe(false);
+ });
+
+ it('closes comment bubble and suppresses immediate comment activation on right-click', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+ document.body.appendChild(wrapper.element);
+
+ const options = wrapper.findComponent(SuperEditorStub).props('options');
+ commentsStoreStub.setActiveComment.mockClear();
+ commentsStoreStub.removePendingComment.mockClear();
+
+ const layersEl = wrapper.find('.superdoc__layers').element;
+ layersEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
+
+ expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(superdocStub, null);
+ expect(commentsStoreStub.removePendingComment).toHaveBeenCalledWith(superdocStub);
+
+ commentsStoreStub.setActiveComment.mockClear();
+ options.onCommentsUpdate({ activeCommentId: 'c1', type: 'trackedChange' });
+ await nextTick();
+
+ expect(commentsStoreStub.setActiveComment).not.toHaveBeenCalledWith(superdocStub, 'c1');
+
+ wrapper.element.remove();
+ });
+
+ it('renders compact comment popover when sidebar is disabled and there is an active thread', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ superdocStoreStub.modules.comments.displayMode = 'inline';
+ wrapper.vm.recalculateCompactCommentsMode();
+ commentsStoreStub.removePendingComment.mockImplementation(() => {
+ commentsStoreStub.pendingComment.value = null;
+ });
+ commentsStoreStub.setActiveComment.mockImplementation((_, commentId) => {
+ commentsStoreStub.activeComment.value = commentId;
+ });
+ commentsStoreStub.pendingComment.value = { commentId: 'pending-1', selection: { selectionBounds: {} } };
+ await nextTick();
+
+ expect(wrapper.vm.showCommentsSidebar).toBe(false);
+ expect(wrapper.vm.activeCompactComment).toBeTruthy();
+ expect(wrapper.find('.superdoc__compact-comment-popover').exists()).toBe(true);
+ expect(wrapper.findComponent(CommentDialogStub).exists()).toBe(true);
+ });
+
+ it('uses PDF DOM anchor fallback for compact popover positioning when stored bounds are unavailable', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ superdocStoreStub.modules.comments.displayMode = 'inline';
+ wrapper.vm.recalculateCompactCommentsMode();
+ commentsStoreStub.pendingComment.value = {
+ commentId: 'pending-1',
+ selection: { source: 'pdf', selectionBounds: {} },
+ };
+ await nextTick();
+
+ const rootEl = wrapper.find('.superdoc').element;
+ const layersEl = wrapper.find('.superdoc__layers').element;
+ const anchorEl = document.createElement('div');
+ anchorEl.className = 'sd-comment-anchor';
+ anchorEl.setAttribute('data-id', 'pending-1');
+ rootEl.appendChild(anchorEl);
+
+ rootEl.getBoundingClientRect = () => ({ top: 50, left: 0, right: 900, bottom: 850, width: 900, height: 800 });
+ layersEl.getBoundingClientRect = () => ({ top: 100, left: 0, right: 800, bottom: 700, width: 800, height: 600 });
+ anchorEl.getBoundingClientRect = () => ({ top: 180, left: 10, right: 30, bottom: 200, width: 20, height: 20 });
+
+ superdocStoreStub.selectionPosition.value = { source: 'pdf', top: 100, left: 10, right: 20, bottom: 120 };
+ await nextTick();
+ await nextTick();
+ const style = wrapper.vm.compactCommentPopoverStyle;
+ expect(style.top).not.toBe('12px');
+ });
+
+ it('re-anchors compact popover when clicking between DOCX comment anchors', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+ document.body.appendChild(wrapper.element);
+
+ superdocStoreStub.modules.comments.displayMode = 'inline';
+ wrapper.vm.recalculateCompactCommentsMode();
+ commentsStoreStub.pendingComment.value = {
+ commentId: 'pending-1',
+ selection: { source: 'super-editor', selectionBounds: {} },
+ };
+ await nextTick();
+
+ const rootEl = wrapper.find('.superdoc').element;
+ const layersEl = wrapper.find('.superdoc__layers').element;
+ rootEl.getBoundingClientRect = () => ({ top: 0, left: 0, right: 1000, bottom: 700, width: 1000, height: 700 });
+ layersEl.getBoundingClientRect = () => ({ top: 0, left: 100, right: 900, bottom: 700, width: 800, height: 700 });
+
+ const anchorA = document.createElement('span');
+ anchorA.className = 'superdoc-comment-highlight';
+ anchorA.setAttribute('data-comment-ids', 'pending');
+ anchorA.getBoundingClientRect = () => ({ top: 100, left: 140, right: 200, bottom: 120, width: 60, height: 20 });
+
+ const anchorB = document.createElement('span');
+ anchorB.className = 'superdoc-comment-highlight';
+ anchorB.setAttribute('data-comment-ids', 'pending');
+ anchorB.getBoundingClientRect = () => ({ top: 180, left: 420, right: 500, bottom: 200, width: 80, height: 20 });
+
+ layersEl.appendChild(anchorA);
+ layersEl.appendChild(anchorB);
+
+ const dispatchPointerDown = (target, x, y) => {
+ const event = new Event('pointerdown', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'button', { value: 0 });
+ Object.defineProperty(event, 'pointerType', { value: 'mouse' });
+ Object.defineProperty(event, 'clientX', { value: x });
+ Object.defineProperty(event, 'clientY', { value: y });
+ target.dispatchEvent(event);
+ };
+
+ dispatchPointerDown(anchorA, 160, 112);
+ await nextTick();
+ const firstStyle = wrapper.vm.compactCommentPopoverStyle;
+
+ dispatchPointerDown(anchorB, 450, 190);
+ await nextTick();
+ const secondStyle = wrapper.vm.compactCommentPopoverStyle;
+
+ expect(firstStyle.left).not.toBe(secondStyle.left);
+ expect(firstStyle.top).not.toBe(secondStyle.top);
+
+ wrapper.element.remove();
+ });
+
+ it('does not early-close compact popover on DOCX pointerdown outside anchors', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+ document.body.appendChild(wrapper.element);
+
+ commentsStoreStub.removePendingComment.mockClear();
+ commentsStoreStub.setActiveComment.mockClear();
+
+ superdocStoreStub.modules.comments.displayMode = 'inline';
+ wrapper.vm.recalculateCompactCommentsMode();
+ commentsStoreStub.pendingComment.value = {
+ commentId: 'pending-1',
+ selection: { source: 'super-editor', selectionBounds: {} },
+ };
+ await nextTick();
+
+ expect(wrapper.find('.superdoc__compact-comment-popover').exists()).toBe(true);
+
+ const layersEl = wrapper.find('.superdoc__layers').element;
+ const outsideNode = document.createElement('div');
+ layersEl.appendChild(outsideNode);
+
+ const event = new Event('pointerdown', { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'button', { value: 0 });
+ Object.defineProperty(event, 'pointerType', { value: 'mouse' });
+ Object.defineProperty(event, 'clientX', { value: 300 });
+ Object.defineProperty(event, 'clientY', { value: 300 });
+ outsideNode.dispatchEvent(event);
+ await nextTick();
+
+ expect(commentsStoreStub.removePendingComment).not.toHaveBeenCalled();
+ expect(commentsStoreStub.setActiveComment).not.toHaveBeenCalledWith(superdocStub, null);
+ expect(wrapper.find('.superdoc__compact-comment-popover').exists()).toBe(true);
+
+ wrapper.element.remove();
+ });
+
+ it('does not render compact comment popover when sidebar remains enabled', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ commentsStoreStub.pendingComment.value = { commentId: 'pending-1', selection: { selectionBounds: {} } };
+ await nextTick();
+
+ expect(wrapper.vm.showCommentsSidebar).toBeTruthy();
+ expect(wrapper.find('.superdoc__compact-comment-popover').exists()).toBe(false);
+ });
+
+ it('closes compact comment popover on Escape and restores focus', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ const trigger = document.createElement('button');
+ trigger.textContent = 'trigger';
+ document.body.appendChild(trigger);
+ trigger.focus();
+
+ superdocStoreStub.modules.comments.displayMode = 'inline';
+ wrapper.vm.recalculateCompactCommentsMode();
+ commentsStoreStub.pendingComment.value = { commentId: 'pending-1', selection: { selectionBounds: {} } };
+ await nextTick();
+
+ expect(wrapper.find('.superdoc__compact-comment-popover').exists()).toBe(true);
+
+ document.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Escape',
+ bubbles: true,
+ }),
+ );
+ await nextTick();
+ await nextTick();
+
+ expect(commentsStoreStub.removePendingComment).toHaveBeenCalled();
+ expect(document.activeElement).toBe(trigger);
+ trigger.remove();
+ });
+
it('hides floating comments sidebar entirely in viewing mode even with comment positions', async () => {
const superdocStub = createSuperdocStub();
superdocStub.config.documentMode = 'viewing';
@@ -1879,6 +2134,145 @@ describe('SuperDoc.vue', () => {
expect(wrapper.find('.superdoc__right-sidebar').exists()).toBe(false);
});
+ it('computes compact comments mode for explicit sidebar/inline display modes', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ superdocStoreStub.modules.comments.displayMode = 'inline';
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+
+ superdocStoreStub.modules.comments.displayMode = 'sidebar';
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(false);
+ });
+
+ it('computes compact comments mode in auto using compactBreakpointPx override', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ const rootEl = wrapper.find('.superdoc').element;
+ const parentEl = rootEl.parentElement;
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 700 });
+ if (parentEl) {
+ Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 700 });
+ }
+
+ superdocStoreStub.modules.comments.displayMode = 'auto';
+ superdocStoreStub.modules.comments.compactBreakpointPx = 760;
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 900 });
+ if (parentEl) {
+ Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 900 });
+ }
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(false);
+ });
+
+ it('switches auto mode directly at compactBreakpointPx threshold', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ const rootEl = wrapper.find('.superdoc').element;
+ const parentEl = rootEl.parentElement;
+ superdocStoreStub.modules.comments.displayMode = 'auto';
+ superdocStoreStub.modules.comments.compactBreakpointPx = 760;
+
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 700 });
+ if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 700 });
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+
+ // Still compact below threshold
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 759 });
+ if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 759 });
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+
+ // Exits compact at/above threshold
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 760 });
+ if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 760 });
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(false);
+ });
+
+ it('switches auto mode directly at measured document threshold when compactBreakpointPx is absent', async () => {
+ const superdocStub = createSuperdocStub();
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ const rootEl = wrapper.find('.superdoc').element;
+ const documentEl = wrapper.find('.superdoc__document').element;
+ const parentEl = rootEl.parentElement;
+ superdocStoreStub.modules.comments.displayMode = 'auto';
+ delete superdocStoreStub.modules.comments.compactBreakpointPx;
+ delete superdocStoreStub.modules.comments.compactMeasurementSelector;
+
+ // Measured threshold = document(816) + sidebar(320) + gutter(24) = 1160
+ Object.defineProperty(documentEl, 'clientWidth', { configurable: true, value: 816 });
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 1130 });
+ if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 1130 });
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+
+ // Still compact below threshold
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 1159 });
+ if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 1159 });
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 1160 });
+ if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 1160 });
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(false);
+ });
+
+ it('uses compactMeasurementSelector as width source in auto mode when target exists', async () => {
+ const superdocStub = createSuperdocStub();
+ const measurementTarget = document.createElement('div');
+ measurementTarget.id = 'compact-shell';
+ Object.defineProperty(measurementTarget, 'clientWidth', { configurable: true, value: 700 });
+ document.body.appendChild(measurementTarget);
+
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ superdocStoreStub.modules.comments.displayMode = 'auto';
+ superdocStoreStub.modules.comments.compactMeasurementSelector = '#compact-shell';
+ superdocStoreStub.modules.comments.compactBreakpointPx = 760;
+ wrapper.vm.recalculateCompactCommentsMode();
+
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+ measurementTarget.remove();
+ });
+
+ it('holds compact state when measured auto width is invalid (0)', async () => {
+ const superdocStub = createSuperdocStub();
+ const measurementTarget = document.createElement('div');
+ measurementTarget.id = 'compact-shell-invalid';
+ Object.defineProperty(measurementTarget, 'clientWidth', { configurable: true, value: 0 });
+ document.body.appendChild(measurementTarget);
+
+ superdocStub.config.modules.comments.compactMeasurementSelector = '#compact-shell-invalid';
+ const wrapper = await mountComponent(superdocStub);
+ await nextTick();
+
+ superdocStoreStub.modules.comments.displayMode = 'inline';
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+
+ superdocStoreStub.modules.comments.displayMode = 'auto';
+ superdocStoreStub.modules.comments.compactBreakpointPx = 760;
+ wrapper.vm.recalculateCompactCommentsMode();
+ expect(wrapper.vm.isCompactCommentsMode).toBe(true);
+ measurementTarget.remove();
+ });
+
it('ignores comment location updates while in viewing mode', async () => {
const superdocStub = createSuperdocStub();
superdocStub.config.documentMode = 'viewing';
diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue
index b1b5cd39da..f70bcaf6cb 100644
--- a/packages/superdoc/src/SuperDoc.vue
+++ b/packages/superdoc/src/SuperDoc.vue
@@ -47,12 +47,19 @@ import AiLayer from './components/AiLayer/AiLayer.vue';
import { useSelectedText } from './composables/use-selected-text';
import { useAi } from './composables/use-ai';
import { useHighContrastMode } from './composables/use-high-contrast-mode';
+import { useCommentSmallScreen } from './composables/use-comment-small-screen.js';
+import { useCompactCommentPopover } from './composables/use-compact-comment-popover.js';
import { getVisibleThreadAnchorClientY } from './helpers/comment-focus.js';
import { useUiFontFamily } from './composables/useUiFontFamily.js';
import { usePasswordPrompt } from './composables/use-password-prompt.js';
import { useFindReplace } from './composables/use-find-replace.js';
import { collectTouchedTrackedChangeIds } from './helpers/collect-touched-tracked-change-ids.js';
import SurfaceHost from './components/surfaces/SurfaceHost.vue';
+import {
+ DEFAULT_COMMENTS_DISPLAY_MODE,
+ RIGHT_CLICK_COMMENT_SUPPRESS_MS,
+ VALID_COMMENTS_DISPLAY_MODES,
+} from './helpers/comment-small-screen.js';
const PdfViewer = defineAsyncComponent(() => import('./components/PdfViewer/PdfViewer.vue'));
const getDocumentLoadPassword = (doc) => doc.password ?? proxy.$superdoc.config.password;
@@ -212,10 +219,20 @@ const superdocRoot = ref(null);
const layers = ref(null);
const pdfViewerRef = ref(null);
const pendingReplayTrackedChangeSync = ref(false);
+const toolsMenuPosition = reactive({ top: null, right: '-25px', zIndex: 101 });
+const {
+ superdocContainerWidth,
+ isCompactCommentsMode,
+ recalculateCompactCommentsMode,
+ ensureCompactMeasurementObserver,
+} = useCommentSmallScreen({
+ commentsModuleConfig,
+ superdocRoot,
+ layers,
+});
// Comments layer
const commentsLayer = ref(null);
-const toolsMenuPosition = reactive({ top: null, right: '-25px', zIndex: 101 });
// Create a ref to pass to the composable
const activeEditorRef = computed(() => proxy.$superdoc.activeEditor);
@@ -713,6 +730,26 @@ const onEditorListdefinitionsChange = (params) => {
proxy.$superdoc.emit('list-definitions-change', params);
};
+let suppressCommentActivationUntilTs = 0;
+
+const markContextMenuOpen = () => {
+ suppressCommentActivationUntilTs = Date.now() + RIGHT_CLICK_COMMENT_SUPPRESS_MS;
+};
+
+const shouldSuppressCommentActivation = () => Date.now() < suppressCommentActivationUntilTs;
+
+const handleDocumentContextMenu = (event) => {
+ const root = superdocRoot.value;
+ if (!root) return;
+ if (!(event.target instanceof Node) || !root.contains(event.target)) return;
+ if (layers.value?.contains(event.target)) {
+ commentsStore.setActiveComment(proxy.$superdoc, null);
+ commentsStore.removePendingComment(proxy.$superdoc);
+ resetClickAnchor();
+ }
+ markContextMenuOpen();
+};
+
const editorOptions = (doc) => {
// We only want to run the font check if the user has provided a callback
// The font check might request extra permissions, and we don't want to run it unless the developer has requested it
@@ -1144,6 +1181,10 @@ const onEditorCommentsUpdate = (params = {}) => {
handleTrackedChangeUpdate({ superdoc: proxy.$superdoc, params });
}
+ if (shouldSyncActiveComment && activeCommentId != null && shouldSuppressCommentActivation()) {
+ shouldSyncActiveComment = false;
+ }
+
if (shouldSyncActiveComment && (activeCommentId == null || !isSameActiveCommentSelection(activeCommentId))) {
syncInstantSidebarAlignmentFromEditorSelection(activeCommentId);
}
@@ -1226,8 +1267,18 @@ const onEditorTransaction = (payload = {}) => {
};
const isCommentsEnabled = computed(() => Boolean(commentsModuleConfig.value));
+const shouldUseSidebarComments = computed(() => {
+ const displayMode = commentsModuleConfig.value?.displayMode ?? DEFAULT_COMMENTS_DISPLAY_MODE;
+ if (!VALID_COMMENTS_DISPLAY_MODES.has(displayMode)) return true;
+ if (displayMode === 'sidebar') return true;
+ if (displayMode === 'inline') return false;
+ // Backward-compatible default: keep sidebar unless integrator explicitly opts into auto.
+ if (displayMode !== 'auto') return true;
+ return !isCompactCommentsMode.value;
+});
const showCommentsSidebar = computed(() => {
if (!shouldRenderCommentsInViewing.value) return false;
+ if (!shouldUseSidebarComments.value) return false;
return (
pendingComment.value ||
(floatingComments.value.length > 0 &&
@@ -1237,7 +1288,27 @@ const showCommentsSidebar = computed(() => {
!isCommentsListVisible.value)
);
});
-
+const activeCompactComment = computed(() => {
+ if (showCommentsSidebar.value) return null;
+ if (!isCommentsEnabled.value) return null;
+ if (pendingComment.value) return pendingComment.value;
+ if (!activeComment.value) return null;
+ return getComment(activeComment.value) ?? null;
+});
+const { compactCommentPopoverStyle, closeCompactCommentPopover, resetClickAnchor } = useCompactCommentPopover({
+ activeComment,
+ pendingComment,
+ activeCompactComment,
+ showCommentsSidebar,
+ superdocRoot,
+ layers,
+ documents,
+ resolveCommentPositionEntry,
+ selectionPosition,
+ activeZoom,
+ clearActiveComment: () => commentsStore.setActiveComment(proxy.$superdoc, null),
+ clearPendingComment: () => commentsStore.removePendingComment(proxy.$superdoc),
+});
const showToolsFloatingMenu = computed(() => {
if (!isCommentsEnabled.value) return false;
return selectionPosition.value && toolsMenuPosition.top && !getConfig.value?.readOnly;
@@ -1246,7 +1317,6 @@ const showActiveSelection = computed(() => {
if (!isCommentsEnabled.value) return false;
return !getConfig.value?.readOnly && selectionPosition.value;
});
-
watch(showCommentsSidebar, (value) => {
proxy.$superdoc.broadcastSidebarToggle(value);
});
@@ -1265,7 +1335,11 @@ onMounted(() => {
if (config && !config.readOnly) {
document.addEventListener('mousedown', handleDocumentMouseDown);
}
+ document.addEventListener('contextmenu', handleDocumentContextMenu, true);
document.addEventListener('keydown', handleDocumentShortcut, true);
+
+ recalculateCompactCommentsMode();
+ ensureCompactMeasurementObserver();
});
function isFindShortcutEvent(e) {
@@ -1320,6 +1394,12 @@ function handleFormattingMarksShortcut(e) {
* do not always leave keyboard focus on a node that bubbles through the root.
*/
function handleDocumentShortcut(e) {
+ if (e.key === 'Escape' && activeCompactComment.value) {
+ e.preventDefault();
+ e.stopPropagation();
+ closeCompactCommentPopover();
+ return;
+ }
handleFindShortcut(e);
if (e.defaultPrevented) return;
handleFormattingMarksShortcut(e);
@@ -1335,6 +1415,7 @@ onBeforeUnmount(() => {
passwordPrompt.destroy();
findReplace.destroy();
document.removeEventListener('mousedown', handleDocumentMouseDown);
+ document.removeEventListener('contextmenu', handleDocumentContextMenu, true);
document.removeEventListener('keydown', handleDocumentShortcut, true);
if (selectionUpdateRafId != null) {
cancelAnimationFrame(selectionUpdateRafId);
@@ -1758,6 +1839,10 @@ const getPDFViewer = () => {
+
+
{
z-index: 2;
}
+.superdoc__compact-comment-popover {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ z-index: 11;
+ width: min(320px, calc(100% - 24px));
+}
+
/* Tools styles */
.tools {
position: absolute;
@@ -1903,7 +1996,6 @@ const getPDFViewer = () => {
.superdoc__right-sidebar {
padding: 10px;
- width: 55px;
position: relative;
}
}
diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue
index 6aac8e72c8..58f861ef48 100644
--- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue
+++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue
@@ -93,6 +93,10 @@ const CLICK_OUTSIDE_HIT_SAMPLE_OFFSETS = [
];
const CLICK_OUTSIDE_IGNORED_SELECTORS = [
'.comments-dropdown__option-label',
+ '.comments-dropdown__menu',
+ '.comments-dropdown__option',
+ '.comments-dropdown__option-icon',
+ '.comments-dropdown__trigger',
'.superdoc-comment-highlight',
'.sd-editor-comment-highlight',
'.sd-editor-tracked-change-highlight',
diff --git a/packages/superdoc/src/composables/use-comment-small-screen.js b/packages/superdoc/src/composables/use-comment-small-screen.js
new file mode 100644
index 0000000000..060d448263
--- /dev/null
+++ b/packages/superdoc/src/composables/use-comment-small-screen.js
@@ -0,0 +1,158 @@
+import { onBeforeUnmount, ref } from 'vue';
+import {
+ DEFAULT_COMMENTS_DISPLAY_MODE,
+ DEFAULT_COMMENTS_MIN_GUTTER_PX,
+ DEFAULT_COMMENTS_SIDEBAR_LANE_PX,
+ DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX,
+} from '../helpers/comment-small-screen.js';
+
+const SUPERDOC_DOCUMENT_SELECTOR = '.superdoc__document';
+
+const isValidCompactBreakpoint = (value) => {
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
+};
+const getRequiredSidebarWidth = (documentWidth) => {
+ return documentWidth + DEFAULT_COMMENTS_SIDEBAR_LANE_PX + DEFAULT_COMMENTS_MIN_GUTTER_PX;
+};
+
+export function useCommentSmallScreen({ commentsModuleConfig, superdocRoot, layers }) {
+ const superdocContainerWidth = ref(0);
+ const isCompactCommentsMode = ref(false);
+
+ let commentsContainerResizeObserver = null;
+ let compactMeasurementTarget = null;
+
+ // A measurement target is valid only if it can provide a meaningful width.
+ // `display: contents` is skipped because it has no own box to measure.
+ const isValidMeasurementTarget = (element) => {
+ if (!(element instanceof HTMLElement)) return false;
+ const computed = typeof window !== 'undefined' ? window.getComputedStyle(element) : null;
+ if (computed?.display === 'contents') return false;
+ const clientWidth = Number(element.clientWidth ?? 0);
+ const rectWidth = Number(element.getBoundingClientRect?.().width ?? 0);
+ return (Number.isFinite(clientWidth) && clientWidth > 0) || (Number.isFinite(rectWidth) && rectWidth > 0);
+ };
+
+ // Resolve where "available width" should be read from:
+ // explicit selector -> nearest measurable ancestor -> superdoc root.
+ const resolveCompactMeasurementTarget = () => {
+ const root = superdocRoot.value;
+ const selector = commentsModuleConfig.value?.compactMeasurementSelector;
+ if (typeof selector === 'string' && selector.trim().length > 0 && typeof document !== 'undefined') {
+ const selected = document.querySelector(selector.trim());
+ if (isValidMeasurementTarget(selected)) return selected;
+ }
+ let ancestor = root?.parentElement ?? null;
+ while (ancestor) {
+ if (isValidMeasurementTarget(ancestor)) return ancestor;
+ ancestor = ancestor.parentElement;
+ }
+ if (isValidMeasurementTarget(root)) return root;
+ if (root instanceof HTMLElement) return root;
+ return null;
+ };
+
+ // Keep a single ResizeObserver bound to the current effective target and
+ // rebind it when selector/DOM structure changes.
+ const ensureCompactMeasurementObserver = () => {
+ const ResizeObserverClass = typeof window !== 'undefined' ? window.ResizeObserver : undefined;
+ if (typeof ResizeObserverClass === 'undefined') return;
+ const nextTarget = resolveCompactMeasurementTarget();
+ if (nextTarget === compactMeasurementTarget) return;
+
+ if (commentsContainerResizeObserver) {
+ commentsContainerResizeObserver.disconnect();
+ commentsContainerResizeObserver = null;
+ }
+
+ compactMeasurementTarget = nextTarget;
+ if (!compactMeasurementTarget) return;
+
+ commentsContainerResizeObserver = new ResizeObserverClass(() => {
+ recalculateCompactCommentsMode();
+ });
+ commentsContainerResizeObserver.observe(compactMeasurementTarget);
+ };
+
+ // Read available width with stable priority (`clientWidth` first, then rect width).
+ const getAvailableCommentsContainerWidth = () => {
+ ensureCompactMeasurementObserver();
+ const clientWidth = Number(compactMeasurementTarget?.clientWidth ?? 0);
+ if (Number.isFinite(clientWidth) && clientWidth > 0) {
+ return clientWidth;
+ }
+ const rectWidth = Number(compactMeasurementTarget?.getBoundingClientRect?.().width ?? 0);
+ if (Number.isFinite(rectWidth) && rectWidth > 0) {
+ return rectWidth;
+ }
+ return 0;
+ };
+
+ // Measure actual document area width; fall back to layers/default when needed.
+ const getMeasuredDocumentWidth = () => {
+ const root = superdocRoot.value;
+ const documentElement = root?.querySelector?.(SUPERDOC_DOCUMENT_SELECTOR);
+ const layersElement = layers.value;
+ const measuredFromDocument = Number(
+ documentElement?.clientWidth ?? documentElement?.getBoundingClientRect?.().width ?? 0,
+ );
+ if (Number.isFinite(measuredFromDocument) && measuredFromDocument > 0) {
+ return measuredFromDocument;
+ }
+ const measuredFromLayers = Number(
+ layersElement?.clientWidth ?? layersElement?.getBoundingClientRect?.().width ?? 0,
+ );
+ if (Number.isFinite(measuredFromLayers) && measuredFromLayers > 0) {
+ return measuredFromLayers;
+ }
+ return DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX;
+ };
+
+ // Compute compact mode from policy:
+ // explicit display mode wins, then optional breakpoint override, then formula threshold.
+ const recalculateCompactCommentsMode = () => {
+ const width = getAvailableCommentsContainerWidth();
+
+ const commentsConfig = commentsModuleConfig.value;
+ const displayMode = commentsConfig?.displayMode ?? DEFAULT_COMMENTS_DISPLAY_MODE;
+ if (displayMode === 'sidebar') {
+ superdocContainerWidth.value = width;
+ isCompactCommentsMode.value = false;
+ return;
+ }
+ if (displayMode === 'inline') {
+ superdocContainerWidth.value = width;
+ isCompactCommentsMode.value = true;
+ return;
+ }
+ if (!(Number.isFinite(width) && width > 0)) {
+ return;
+ }
+ superdocContainerWidth.value = width;
+
+ const configuredBreakpoint = commentsConfig?.compactBreakpointPx;
+ if (isValidCompactBreakpoint(configuredBreakpoint)) {
+ isCompactCommentsMode.value = width < configuredBreakpoint;
+ return;
+ }
+
+ const measuredDocumentWidth = getMeasuredDocumentWidth();
+ const requiredWidth = getRequiredSidebarWidth(measuredDocumentWidth);
+ isCompactCommentsMode.value = width < requiredWidth;
+ };
+
+ onBeforeUnmount(() => {
+ if (commentsContainerResizeObserver) {
+ commentsContainerResizeObserver.disconnect();
+ commentsContainerResizeObserver = null;
+ }
+ compactMeasurementTarget = null;
+ });
+
+ return {
+ superdocContainerWidth,
+ isCompactCommentsMode,
+ recalculateCompactCommentsMode,
+ ensureCompactMeasurementObserver,
+ };
+}
diff --git a/packages/superdoc/src/composables/use-comment-small-screen.test.js b/packages/superdoc/src/composables/use-comment-small-screen.test.js
new file mode 100644
index 0000000000..55fc250b6f
--- /dev/null
+++ b/packages/superdoc/src/composables/use-comment-small-screen.test.js
@@ -0,0 +1,275 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { defineComponent, h, ref } from 'vue';
+import { mount } from '@vue/test-utils';
+import {
+ DEFAULT_COMMENTS_MIN_GUTTER_PX,
+ DEFAULT_COMMENTS_SIDEBAR_LANE_PX,
+ DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX,
+} from '../helpers/comment-small-screen.js';
+import { useCommentSmallScreen } from './use-comment-small-screen.js';
+
+const setClientWidth = (el, value) => {
+ Object.defineProperty(el, 'clientWidth', {
+ configurable: true,
+ get: () => value,
+ });
+};
+
+const setRectWidth = (el, value) => {
+ el.getBoundingClientRect = vi.fn(() => ({ width: value }));
+};
+
+describe('useCommentSmallScreen', () => {
+ let root;
+ let parent;
+ let layers;
+ let commentsModuleConfig;
+
+ const mountComposable = () => {
+ let api;
+ const Harness = defineComponent({
+ setup() {
+ api = useCommentSmallScreen({ commentsModuleConfig, superdocRoot: ref(root), layers: ref(layers) });
+ return () => h('div');
+ },
+ });
+ const wrapper = mount(Harness);
+ return { api, wrapper };
+ };
+
+ const createMockResizeObserver = () => {
+ const instances = [];
+ const Original = window.ResizeObserver;
+ window.ResizeObserver = vi.fn((cb) => {
+ const instance = {
+ observe: vi.fn(),
+ disconnect: vi.fn(),
+ _cb: cb,
+ };
+ instances.push(instance);
+ return instance;
+ });
+ return {
+ instances,
+ restore: () => {
+ window.ResizeObserver = Original;
+ },
+ };
+ };
+
+ beforeEach(() => {
+ document.body.innerHTML = '';
+
+ parent = document.createElement('div');
+ root = document.createElement('div');
+ layers = document.createElement('div');
+
+ root.appendChild(layers);
+ parent.appendChild(root);
+ document.body.appendChild(parent);
+
+ setClientWidth(parent, 1200);
+ setClientWidth(root, 1000);
+ setClientWidth(layers, 816);
+
+ commentsModuleConfig = ref({ displayMode: 'auto' });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.innerHTML = '';
+ });
+
+ it('forces sidebar mode when displayMode is sidebar', () => {
+ commentsModuleConfig.value = { displayMode: 'sidebar' };
+ const { api: state, wrapper } = mountComposable();
+
+ state.recalculateCompactCommentsMode();
+
+ expect(state.isCompactCommentsMode.value).toBe(false);
+ expect(state.superdocContainerWidth.value).toBe(1200);
+ wrapper.unmount();
+ });
+
+ it('forces inline mode when displayMode is inline', () => {
+ commentsModuleConfig.value = { displayMode: 'inline' };
+ const { api: state, wrapper } = mountComposable();
+
+ state.recalculateCompactCommentsMode();
+
+ expect(state.isCompactCommentsMode.value).toBe(true);
+ expect(state.superdocContainerWidth.value).toBe(1200);
+ wrapper.unmount();
+ });
+
+ it('uses compactBreakpointPx when configured', () => {
+ commentsModuleConfig.value = { displayMode: 'auto', compactBreakpointPx: 1100 };
+ const { api: state, wrapper } = mountComposable();
+
+ state.recalculateCompactCommentsMode();
+
+ expect(state.superdocContainerWidth.value).toBe(1200);
+ expect(state.isCompactCommentsMode.value).toBe(false);
+
+ setClientWidth(parent, 900);
+ state.recalculateCompactCommentsMode();
+ expect(state.isCompactCommentsMode.value).toBe(true);
+ wrapper.unmount();
+ });
+
+ it('uses measured document width formula when no explicit breakpoint', () => {
+ commentsModuleConfig.value = { displayMode: 'auto' };
+ const documentEl = document.createElement('div');
+ documentEl.className = 'superdoc__document';
+ root.appendChild(documentEl);
+ setClientWidth(documentEl, 840);
+
+ const { api: state, wrapper } = mountComposable();
+
+ // required = docWidth + sidebar + gutter
+ const required = 840 + DEFAULT_COMMENTS_SIDEBAR_LANE_PX + DEFAULT_COMMENTS_MIN_GUTTER_PX;
+ setClientWidth(parent, required - 1);
+ state.recalculateCompactCommentsMode();
+ expect(state.isCompactCommentsMode.value).toBe(true);
+
+ setClientWidth(parent, required + 1);
+ state.recalculateCompactCommentsMode();
+ expect(state.isCompactCommentsMode.value).toBe(false);
+ wrapper.unmount();
+ });
+
+ it('falls back to default document width when document/layers width is unavailable', () => {
+ commentsModuleConfig.value = { displayMode: 'auto' };
+ setClientWidth(layers, 0);
+ setRectWidth(layers, 0);
+
+ const { api: state, wrapper } = mountComposable();
+
+ const required =
+ DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX + DEFAULT_COMMENTS_SIDEBAR_LANE_PX + DEFAULT_COMMENTS_MIN_GUTTER_PX;
+ setClientWidth(parent, required - 1);
+ state.recalculateCompactCommentsMode();
+ expect(state.isCompactCommentsMode.value).toBe(true);
+
+ setClientWidth(parent, required + 1);
+ state.recalculateCompactCommentsMode();
+ expect(state.isCompactCommentsMode.value).toBe(false);
+ wrapper.unmount();
+ });
+
+ it('uses compactMeasurementSelector when provided', () => {
+ const shell = document.createElement('div');
+ shell.id = 'measurement-shell';
+ document.body.appendChild(shell);
+ setClientWidth(shell, 777);
+
+ commentsModuleConfig.value = {
+ displayMode: 'sidebar',
+ compactMeasurementSelector: '#measurement-shell',
+ };
+
+ const { api: state, wrapper } = mountComposable();
+ state.recalculateCompactCommentsMode();
+
+ expect(state.superdocContainerWidth.value).toBe(777);
+ wrapper.unmount();
+ });
+
+ it('falls back to rect width when clientWidth is zero', () => {
+ const selectorTarget = document.createElement('div');
+ selectorTarget.id = 'rect-only';
+ document.body.appendChild(selectorTarget);
+ setClientWidth(selectorTarget, 0);
+ setRectWidth(selectorTarget, 654);
+
+ commentsModuleConfig.value = {
+ displayMode: 'sidebar',
+ compactMeasurementSelector: '#rect-only',
+ };
+
+ const { api: state, wrapper } = mountComposable();
+ state.recalculateCompactCommentsMode();
+ expect(state.superdocContainerWidth.value).toBe(654);
+ wrapper.unmount();
+ });
+
+ it('falls back to layers width when document width is unavailable', () => {
+ commentsModuleConfig.value = { displayMode: 'auto' };
+ const documentEl = document.createElement('div');
+ documentEl.className = 'superdoc__document';
+ root.appendChild(documentEl);
+ setClientWidth(documentEl, 0);
+ setRectWidth(documentEl, 0);
+
+ setClientWidth(layers, 700);
+
+ const { api: state, wrapper } = mountComposable();
+ const required = 700 + DEFAULT_COMMENTS_SIDEBAR_LANE_PX + DEFAULT_COMMENTS_MIN_GUTTER_PX;
+ setClientWidth(parent, required - 1);
+ state.recalculateCompactCommentsMode();
+ expect(state.isCompactCommentsMode.value).toBe(true);
+ wrapper.unmount();
+ });
+
+ it('calls recalculate from ResizeObserver callback', () => {
+ const ro = createMockResizeObserver();
+ const { api: state, wrapper } = mountComposable();
+
+ state.ensureCompactMeasurementObserver();
+ expect(ro.instances.length).toBeGreaterThan(0);
+ expect(state.superdocContainerWidth.value).toBe(0);
+
+ setClientWidth(parent, 432);
+ ro.instances[0]._cb();
+
+ expect(state.superdocContainerWidth.value).toBe(432);
+ wrapper.unmount();
+ ro.restore();
+ });
+
+ it('returns null measurement target when root is missing', () => {
+ const detachedConfig = ref({ displayMode: 'auto' });
+ let api;
+ const Harness = defineComponent({
+ setup() {
+ api = useCommentSmallScreen({
+ commentsModuleConfig: detachedConfig,
+ superdocRoot: ref(null),
+ layers: ref(null),
+ });
+ return () => h('div');
+ },
+ });
+ const wrapper = mount(Harness);
+ api.recalculateCompactCommentsMode();
+ expect(api.superdocContainerWidth.value).toBe(0);
+ wrapper.unmount();
+ });
+
+ it('does not throw when ResizeObserver is unavailable', () => {
+ const originalResizeObserver = window.ResizeObserver;
+ delete window.ResizeObserver;
+
+ const { api: state, wrapper } = mountComposable();
+ expect(() => state.ensureCompactMeasurementObserver()).not.toThrow();
+ wrapper.unmount();
+
+ window.ResizeObserver = originalResizeObserver;
+ });
+
+ it('disconnects ResizeObserver on unmount', () => {
+ const disconnect = vi.fn();
+ const observe = vi.fn();
+ const originalResizeObserver = window.ResizeObserver;
+ window.ResizeObserver = vi.fn(() => ({ observe, disconnect }));
+
+ const { api, wrapper } = mountComposable();
+ api.ensureCompactMeasurementObserver();
+
+ expect(observe).toHaveBeenCalled();
+ wrapper.unmount();
+ expect(disconnect).toHaveBeenCalledTimes(1);
+
+ window.ResizeObserver = originalResizeObserver;
+ });
+});
diff --git a/packages/superdoc/src/composables/use-compact-comment-popover.js b/packages/superdoc/src/composables/use-compact-comment-popover.js
new file mode 100644
index 0000000000..f73420c32e
--- /dev/null
+++ b/packages/superdoc/src/composables/use-compact-comment-popover.js
@@ -0,0 +1,336 @@
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { PDF } from '@superdoc/common';
+import { COMPACT_ANCHOR_SELECTOR } from '../helpers/comment-small-screen.js';
+
+const POPOVER_WIDTH_PX = 320;
+const SAFE_MARGIN_PX = 12;
+const MIN_BOTTOM_SPACE_PX = 220;
+const ANCHOR_TOP_OFFSET_PX = 16;
+const INTERACTION_ANCHOR_TTL_MS = 500;
+
+const COMMENT_HIGHLIGHT_SELECTOR = '.superdoc-comment-highlight[data-comment-ids]';
+const COMMENT_HIGHLIGHT_DATA_ATTR = 'data-comment-ids';
+const PDF_COMMENT_ANCHOR_SELECTOR = '.sd-comment-anchor';
+
+// Clamp a value into the given inclusive range.
+const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
+const toNumber = (value) => Number(value);
+const isFiniteNumber = (value) => Number.isFinite(value);
+
+const getCommentAnchorId = (comment) => comment?.commentId ?? comment?.importedId ?? null;
+
+const parseCommentIds = (value) =>
+ String(value || '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+// Click anchor from pointerdown tracking: returns { x, y } in clientX/clientY space if within TTL.
+const resolveInteractionAnchor = (lastClickAnchor) => {
+ const anchor = lastClickAnchor?.value;
+ if (!anchor) return null;
+ const ts = toNumber(anchor.ts);
+ if (!isFiniteNumber(ts) || Date.now() - ts > INTERACTION_ANCHOR_TTL_MS) return null;
+ const x = toNumber(anchor.x);
+ const rectBottom = toNumber(anchor.anchorRect?.bottom);
+ const y = isFiniteNumber(rectBottom) ? rectBottom : toNumber(anchor.y);
+ if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null;
+ return { x, y };
+};
+
+// DOM fallback: find the first highlight/TC span for the comment and return its bounding rect.
+const resolveInlineHighlightAnchorRect = ({ rootEl, comment, pendingComment }) => {
+ const isPending = Boolean(pendingComment?.value && comment?.commentId === pendingComment.value?.commentId);
+ if (isPending) {
+ const nodes = rootEl.querySelectorAll(COMMENT_HIGHLIGHT_SELECTOR);
+ for (const node of nodes) {
+ if (!parseCommentIds(node.getAttribute(COMMENT_HIGHLIGHT_DATA_ATTR)).includes('pending')) continue;
+ if (typeof node.getBoundingClientRect !== 'function') continue;
+ return node.getBoundingClientRect();
+ }
+ }
+
+ const anchorId = getCommentAnchorId(comment);
+ if (!anchorId) return null;
+ const safeId = String(anchorId);
+
+ const highlightNodes = rootEl.querySelectorAll(COMMENT_HIGHLIGHT_SELECTOR);
+ for (const node of highlightNodes) {
+ if (!parseCommentIds(node.getAttribute(COMMENT_HIGHLIGHT_DATA_ATTR)).includes(safeId)) continue;
+ if (typeof node.getBoundingClientRect !== 'function') continue;
+ return node.getBoundingClientRect();
+ }
+
+ const tcNode = rootEl.querySelector(`[data-track-change-id="${safeId}"]`);
+ if (tcNode && typeof tcNode.getBoundingClientRect === 'function') return tcNode.getBoundingClientRect();
+
+ return null;
+};
+
+// Primary anchor source: stored layout bounds for the active comment thread.
+const resolveEntryAnchorBottom = (resolveCommentPositionEntry, comment) => {
+ const { entry } = resolveCommentPositionEntry(getCommentAnchorId(comment));
+ const boundsBottom = toNumber(entry?.bounds?.bottom);
+ if (isFiniteNumber(boundsBottom)) return boundsBottom;
+ return toNumber(entry?.bounds?.top);
+};
+
+// Allow DOM-anchor fallback only in PDF-related contexts.
+const isPdfContextForAnchorLookup = ({ selectionPosition, comment, pendingComment }) =>
+ selectionPosition.value?.source === 'pdf' ||
+ comment?.selection?.source === 'pdf' ||
+ pendingComment.value?.selection?.source === 'pdf';
+
+// PDF fallback when stored bounds are missing: read anchor position from DOM.
+const resolvePdfDomAnchorBottom = ({ rootEl, layersRect, comment }) => {
+ const anchorId = getCommentAnchorId(comment);
+ if (anchorId == null) return NaN;
+ const anchorElement = rootEl.querySelector(`${PDF_COMMENT_ANCHOR_SELECTOR}[data-id="${String(anchorId)}"]`);
+ if (!anchorElement || typeof anchorElement.getBoundingClientRect !== 'function') return NaN;
+ const anchorRect = anchorElement.getBoundingClientRect();
+ return toNumber(anchorRect.bottom) - toNumber(layersRect.top);
+};
+
+// Pending-comment fallback: derive anchor position from current selection coordinates.
+const resolvePendingSelectionAnchorBottom = ({ comment, pendingComment, selectionPosition, activeZoom }) => {
+ if (!pendingComment.value || comment?.commentId !== pendingComment.value?.commentId) return NaN;
+ const selectedBottom = toNumber(selectionPosition.value?.bottom);
+ if (isFiniteNumber(selectedBottom)) {
+ const isPdf = selectionPosition.value?.source === 'pdf';
+ const zoom = isPdf ? (activeZoom.value ?? 100) / 100 : 1;
+ return selectedBottom * zoom;
+ }
+ const selectedTop = toNumber(selectionPosition.value?.top);
+ if (!isFiniteNumber(selectedTop)) return NaN;
+ const isPdf = selectionPosition.value?.source === 'pdf';
+ const zoom = isPdf ? (activeZoom.value ?? 100) / 100 : 1;
+ return selectedTop * zoom;
+};
+
+const toPopoverStyle = ({ top, left }) => ({
+ top: `${Math.round(top)}px`,
+ left: `${Math.round(left)}px`,
+ right: 'auto',
+});
+
+// Keep the popover inside the superdoc viewport with safe margins.
+const resolvePopoverPosition = ({ rootRect, layersRect, anchorBottom, anchorClientX = NaN }) => {
+ const idealTop = layersRect.top - rootRect.top + anchorBottom + ANCHOR_TOP_OFFSET_PX;
+ const maxTop = Math.max(SAFE_MARGIN_PX, rootRect.height - MIN_BOTTOM_SPACE_PX);
+ const top = clamp(idealTop, SAFE_MARGIN_PX, maxTop);
+
+ const maxLeft = Math.max(SAFE_MARGIN_PX, rootRect.width - POPOVER_WIDTH_PX - SAFE_MARGIN_PX);
+ let left;
+ if (isFiniteNumber(anchorClientX)) {
+ left = clamp(anchorClientX - rootRect.left + SAFE_MARGIN_PX, SAFE_MARGIN_PX, maxLeft);
+ } else {
+ const rightCandidate = rootRect.width - (layersRect.left - rootRect.left + layersRect.width) + SAFE_MARGIN_PX;
+ left = clamp(rootRect.width - rightCandidate - POPOVER_WIDTH_PX, SAFE_MARGIN_PX, maxLeft);
+ }
+
+ return { top, left };
+};
+
+export function useCompactCommentPopover({
+ activeComment,
+ pendingComment,
+ activeCompactComment,
+ showCommentsSidebar,
+ selectionPosition,
+ activeZoom,
+ superdocRoot,
+ layers,
+ documents,
+ resolveCommentPositionEntry,
+ clearActiveComment,
+ clearPendingComment,
+}) {
+ const fallback = {
+ top: '12px',
+ right: '12px',
+ };
+ const compactPopoverLayoutTick = ref(0);
+ const lastClickAnchor = ref({ x: null, y: null, ts: 0, anchorRect: null });
+
+ let compactPopoverRafId = null;
+ let compactPopoverReturnFocusEl = null;
+
+ const resetClickAnchor = () => {
+ lastClickAnchor.value = { x: null, y: null, ts: 0, anchorRect: null };
+ };
+
+ const clearCompactPopoverIfPdfClickedOutside = (root, target, anchorElement) => {
+ const isPdfDocument = documents.value?.some((doc) => doc.type === PDF);
+ if (!isPdfDocument) return false;
+ const compactPopoverEl = root.querySelector('.superdoc__compact-comment-popover');
+ if (!activeCompactComment.value || !compactPopoverEl || compactPopoverEl.contains(target) || anchorElement) {
+ return false;
+ }
+ if (pendingComment.value) {
+ clearPendingComment();
+ clearActiveComment();
+ } else {
+ clearActiveComment();
+ }
+ resetClickAnchor();
+ return true;
+ };
+
+ const trackCompactPopoverClickAnchor = (e) => {
+ const root = superdocRoot.value;
+ if (!root || !layers.value?.contains(e.target)) return;
+
+ const elementsAtPoint =
+ typeof document.elementsFromPoint === 'function' ? document.elementsFromPoint(e.clientX, e.clientY) : [];
+
+ const anchorElement =
+ elementsAtPoint
+ .find((node) => node?.nodeType === 1 && root.contains(node) && node.closest(COMPACT_ANCHOR_SELECTOR))
+ ?.closest(COMPACT_ANCHOR_SELECTOR) ??
+ (e.target?.nodeType === 1 ? e.target.closest(COMPACT_ANCHOR_SELECTOR) : null);
+
+ if (clearCompactPopoverIfPdfClickedOutside(root, e.target, anchorElement)) return;
+
+ if (e.button !== 0 || e.pointerType !== 'mouse') return;
+
+ const anchorRect =
+ anchorElement && typeof anchorElement.getBoundingClientRect === 'function'
+ ? anchorElement.getBoundingClientRect()
+ : null;
+ if (!anchorRect) return;
+
+ lastClickAnchor.value = {
+ x: e.clientX,
+ y: anchorRect.bottom,
+ ts: Date.now(),
+ anchorRect: { left: anchorRect.left, right: anchorRect.right, top: anchorRect.top, bottom: anchorRect.bottom },
+ };
+ };
+
+ const compactCommentPopoverStyle = computed(() => {
+ void compactPopoverLayoutTick.value;
+
+ const comment = activeCompactComment.value;
+ if (!comment) return fallback;
+
+ const rootEl = superdocRoot.value;
+ const layersEl = layers.value;
+ if (!rootEl || !layersEl) return fallback;
+
+ const rootRect = rootEl.getBoundingClientRect();
+ const layersRect = layersEl.getBoundingClientRect();
+
+ // 1. Click anchor (most accurate — where user actually clicked).
+ const interactionAnchor = resolveInteractionAnchor(lastClickAnchor);
+ // 2. DOM highlight rect (keyboard/API fallback — first span of the comment/TC).
+ const inlineRect = !interactionAnchor
+ ? resolveInlineHighlightAnchorRect({ rootEl, comment, pendingComment })
+ : null;
+
+ let anchorBottom = NaN;
+ let anchorClientX = NaN;
+
+ if (interactionAnchor) {
+ anchorBottom = interactionAnchor.y - layersRect.top;
+ anchorClientX = interactionAnchor.x;
+ } else if (inlineRect) {
+ anchorBottom = inlineRect.bottom - layersRect.top;
+ anchorClientX = inlineRect.left;
+ } else {
+ // 3. Stored layout bounds.
+ anchorBottom = resolveEntryAnchorBottom(resolveCommentPositionEntry, comment);
+ }
+
+ // 4. PDF DOM anchor fallback.
+ if (!isFiniteNumber(anchorBottom) && isPdfContextForAnchorLookup({ selectionPosition, comment, pendingComment })) {
+ anchorBottom = resolvePdfDomAnchorBottom({ rootEl, layersRect, comment });
+ }
+
+ // 5. Pending selection fallback.
+ if (!isFiniteNumber(anchorBottom)) {
+ anchorBottom = resolvePendingSelectionAnchorBottom({
+ comment,
+ pendingComment,
+ selectionPosition,
+ activeZoom,
+ });
+ }
+
+ if (!isFiniteNumber(anchorBottom)) return fallback;
+
+ const position = resolvePopoverPosition({ rootRect, layersRect, anchorBottom, anchorClientX });
+ return toPopoverStyle(position);
+ });
+
+ // Recompute style on compact-popover-relevant state changes via RAF.
+ watch(
+ [activeComment, pendingComment, selectionPosition, activeZoom, showCommentsSidebar, lastClickAnchor],
+ () => {
+ const requestAnimationFrameFn = typeof window !== 'undefined' ? window.requestAnimationFrame : null;
+ const cancelAnimationFrameFn = typeof window !== 'undefined' ? window.cancelAnimationFrame : null;
+ if (compactPopoverRafId != null) {
+ cancelAnimationFrameFn?.(compactPopoverRafId);
+ }
+ if (!requestAnimationFrameFn) return;
+ compactPopoverRafId = requestAnimationFrameFn(() => {
+ compactPopoverLayoutTick.value += 1;
+ compactPopoverRafId = null;
+ });
+ },
+ { deep: false },
+ );
+
+ // Capture focus source when compact popover becomes active.
+ watch(
+ activeCompactComment,
+ (current, previous) => {
+ if (!previous && current) {
+ const activeEl = document.activeElement;
+ if (activeEl instanceof HTMLElement) {
+ compactPopoverReturnFocusEl = activeEl;
+ }
+ }
+ },
+ { deep: false },
+ );
+
+ // Reset stale click anchor when a new pending comment opens.
+ watch(pendingComment, (current, previous) => {
+ if (!previous && current) resetClickAnchor();
+ });
+
+ onMounted(() => {
+ document.addEventListener('pointerdown', trackCompactPopoverClickAnchor, true);
+ });
+
+ onBeforeUnmount(() => {
+ document.removeEventListener('pointerdown', trackCompactPopoverClickAnchor, true);
+ const cancelAnimationFrameFn = typeof window !== 'undefined' ? window.cancelAnimationFrame : null;
+ if (compactPopoverRafId != null) {
+ cancelAnimationFrameFn?.(compactPopoverRafId);
+ compactPopoverRafId = null;
+ }
+ compactPopoverReturnFocusEl = null;
+ });
+
+ // Close compact popover and restore focus to the triggering element.
+ const closeCompactCommentPopover = () => {
+ if (!activeCompactComment.value) return;
+ if (pendingComment.value) {
+ clearPendingComment();
+ clearActiveComment();
+ } else {
+ clearActiveComment();
+ }
+ if (compactPopoverReturnFocusEl && typeof compactPopoverReturnFocusEl.focus === 'function') {
+ compactPopoverReturnFocusEl.focus();
+ }
+ compactPopoverReturnFocusEl = null;
+ };
+
+ return {
+ compactCommentPopoverStyle,
+ closeCompactCommentPopover,
+ resetClickAnchor,
+ };
+}
diff --git a/packages/superdoc/src/composables/use-compact-comment-popover.test.js b/packages/superdoc/src/composables/use-compact-comment-popover.test.js
new file mode 100644
index 0000000000..a05a14f0ee
--- /dev/null
+++ b/packages/superdoc/src/composables/use-compact-comment-popover.test.js
@@ -0,0 +1,327 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { defineComponent, h, nextTick, ref } from 'vue';
+import { mount } from '@vue/test-utils';
+import { PDF } from '@superdoc/common';
+import { useCompactCommentPopover } from './use-compact-comment-popover.js';
+
+const rect = (left, top, width, height) => ({
+ left,
+ top,
+ width,
+ height,
+ right: left + width,
+ bottom: top + height,
+});
+
+const setRect = (el, r) => {
+ el.getBoundingClientRect = vi.fn(() => r);
+};
+
+const dispatchPointerDown = (target, { clientX, clientY, button = 0, pointerType = 'mouse' }) => {
+ const event = new MouseEvent('pointerdown', { bubbles: true, button, clientX, clientY });
+ Object.defineProperty(event, 'pointerType', { value: pointerType });
+ target.dispatchEvent(event);
+};
+
+describe('useCompactCommentPopover', () => {
+ let root;
+ let layers;
+ let popover;
+
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ root = document.createElement('div');
+ layers = document.createElement('div');
+ popover = document.createElement('div');
+
+ root.className = 'superdoc';
+ layers.className = 'superdoc__layers';
+ popover.className = 'superdoc__compact-comment-popover';
+
+ root.appendChild(layers);
+ root.appendChild(popover);
+ document.body.appendChild(root);
+
+ setRect(root, rect(0, 0, 1200, 900));
+ setRect(layers, rect(100, 80, 816, 700));
+ setRect(popover, rect(600, 200, 320, 220));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.innerHTML = '';
+ });
+
+ const mountComposable = (overrides = {}) => {
+ const activeComment = overrides.activeComment ?? ref(null);
+ const pendingComment = overrides.pendingComment ?? ref(null);
+ const activeCompactComment = overrides.activeCompactComment ?? ref(null);
+ const showCommentsSidebar = overrides.showCommentsSidebar ?? ref(false);
+ const selectionPosition = overrides.selectionPosition ?? ref(null);
+ const activeZoom = overrides.activeZoom ?? ref(100);
+ const documents = overrides.documents ?? ref([]);
+ const clearActiveComment = overrides.clearActiveComment ?? vi.fn();
+ const clearPendingComment = overrides.clearPendingComment ?? vi.fn();
+ const resolveCommentPositionEntry =
+ overrides.resolveCommentPositionEntry ?? vi.fn(() => ({ entry: { bounds: { top: 120, bottom: 140 } } }));
+
+ let api;
+ const Harness = defineComponent({
+ setup() {
+ api = useCompactCommentPopover({
+ activeComment,
+ pendingComment,
+ activeCompactComment,
+ showCommentsSidebar,
+ selectionPosition,
+ activeZoom,
+ superdocRoot: ref(root),
+ layers: ref(layers),
+ documents,
+ resolveCommentPositionEntry,
+ clearActiveComment,
+ clearPendingComment,
+ });
+ return () => h('div');
+ },
+ });
+
+ const wrapper = mount(Harness);
+ return {
+ wrapper,
+ api,
+ refs: {
+ activeComment,
+ pendingComment,
+ activeCompactComment,
+ showCommentsSidebar,
+ selectionPosition,
+ activeZoom,
+ documents,
+ },
+ fns: { clearActiveComment, clearPendingComment, resolveCommentPositionEntry },
+ };
+ };
+
+ it('returns fallback style when there is no active compact comment', () => {
+ const { api, wrapper } = mountComposable();
+
+ expect(api.compactCommentPopoverStyle.value).toEqual({ top: '12px', right: '12px' });
+
+ wrapper.unmount();
+ });
+
+ it('computes position from interaction anchor tracked by pointerdown', async () => {
+ const anchor = document.createElement('span');
+ anchor.className = 'superdoc-comment-highlight';
+ anchor.setAttribute('data-comment-ids', 'c-1');
+ setRect(anchor, rect(240, 300, 40, 20));
+ layers.appendChild(anchor);
+
+ const originalElementsFromPoint = document.elementsFromPoint;
+ document.elementsFromPoint = vi.fn(() => [anchor]);
+
+ const { api, refs, wrapper } = mountComposable({
+ activeCompactComment: ref({ commentId: 'c-1' }),
+ });
+
+ dispatchPointerDown(anchor, { clientX: 260, clientY: 305, pointerType: 'mouse' });
+ await nextTick();
+
+ const style = api.compactCommentPopoverStyle.value;
+ expect(style.top).toBeDefined();
+ expect(style.left).toBeDefined();
+ expect(style.right).toBe('auto');
+
+ document.elementsFromPoint = originalElementsFromPoint;
+ refs.activeCompactComment.value = null;
+ wrapper.unmount();
+ });
+
+ it('falls back to inline highlight rect when there is no recent interaction anchor', () => {
+ const highlight = document.createElement('span');
+ highlight.className = 'superdoc-comment-highlight';
+ highlight.setAttribute('data-comment-ids', 'thread-1');
+ setRect(highlight, rect(180, 260, 60, 24));
+ root.appendChild(highlight);
+
+ const { api, wrapper } = mountComposable({
+ activeCompactComment: ref({ commentId: 'thread-1' }),
+ resolveCommentPositionEntry: vi.fn(() => ({ entry: { bounds: {} } })),
+ });
+
+ const style = api.compactCommentPopoverStyle.value;
+ expect(style.top).toBeDefined();
+ expect(style.left).toBeDefined();
+ expect(style.right).toBe('auto');
+
+ wrapper.unmount();
+ });
+
+ it('skips highlight nodes without getBoundingClientRect and falls back to tracked-change node', () => {
+ const brokenHighlight = document.createElement('span');
+ brokenHighlight.className = 'superdoc-comment-highlight';
+ brokenHighlight.setAttribute('data-comment-ids', 'thread-2');
+ // Simulate a malformed node branch: no callable rect API.
+ brokenHighlight.getBoundingClientRect = undefined;
+ root.appendChild(brokenHighlight);
+
+ const tcNode = document.createElement('span');
+ tcNode.setAttribute('data-track-change-id', 'thread-2');
+ setRect(tcNode, rect(200, 280, 30, 18));
+ root.appendChild(tcNode);
+
+ const { api, wrapper } = mountComposable({
+ activeCompactComment: ref({ commentId: 'thread-2' }),
+ resolveCommentPositionEntry: vi.fn(() => ({ entry: { bounds: {} } })),
+ });
+
+ const style = api.compactCommentPopoverStyle.value;
+ expect(style.top).toBeDefined();
+ expect(style.left).toBeDefined();
+
+ wrapper.unmount();
+ });
+
+ it('uses pending selection fallback with PDF zoom when no anchor data exists', () => {
+ const { api, wrapper } = mountComposable({
+ activeCompactComment: ref({ commentId: 'pending-id' }),
+ pendingComment: ref({ commentId: 'pending-id' }),
+ selectionPosition: ref({ source: 'pdf', top: 50, bottom: 100 }),
+ activeZoom: ref(150),
+ resolveCommentPositionEntry: vi.fn(() => ({ entry: { bounds: {} } })),
+ });
+
+ const style = api.compactCommentPopoverStyle.value;
+ expect(style.top).toBeDefined();
+ expect(style.left).toBeDefined();
+
+ wrapper.unmount();
+ });
+
+ it('clears pending + active comment and restores focus when close is called', () => {
+ const focusEl = document.createElement('button');
+ document.body.appendChild(focusEl);
+ focusEl.focus();
+
+ const clearActiveComment = vi.fn();
+ const clearPendingComment = vi.fn();
+
+ const { api, wrapper } = mountComposable({
+ activeCompactComment: ref({ commentId: 'x' }),
+ pendingComment: ref({ commentId: 'x' }),
+ clearActiveComment,
+ clearPendingComment,
+ });
+
+ api.closeCompactCommentPopover();
+
+ expect(clearPendingComment).toHaveBeenCalledTimes(1);
+ expect(clearActiveComment).toHaveBeenCalledTimes(1);
+
+ wrapper.unmount();
+ });
+
+ it('closes PDF compact popover on outside pointerdown inside layers', async () => {
+ const nonAnchorTarget = document.createElement('div');
+ layers.appendChild(nonAnchorTarget);
+
+ const originalElementsFromPoint = document.elementsFromPoint;
+ document.elementsFromPoint = vi.fn(() => [nonAnchorTarget]);
+
+ const clearActiveComment = vi.fn();
+ const { wrapper } = mountComposable({
+ activeCompactComment: ref({ commentId: 'pdf-thread' }),
+ documents: ref([{ type: PDF }]),
+ clearActiveComment,
+ });
+
+ dispatchPointerDown(nonAnchorTarget, { clientX: 120, clientY: 140, pointerType: 'mouse' });
+ await nextTick();
+
+ expect(clearActiveComment).toHaveBeenCalledTimes(1);
+
+ document.elementsFromPoint = originalElementsFromPoint;
+ wrapper.unmount();
+ });
+
+ it('keeps non-mouse pointerdown out of click-anchor tracking', async () => {
+ const anchor = document.createElement('span');
+ anchor.className = 'superdoc-comment-highlight';
+ anchor.setAttribute('data-comment-ids', 'c-touch');
+ setRect(anchor, rect(260, 320, 40, 20));
+ layers.appendChild(anchor);
+
+ const originalElementsFromPoint = document.elementsFromPoint;
+ document.elementsFromPoint = vi.fn(() => [anchor]);
+
+ const clearActiveComment = vi.fn();
+ const { api, wrapper } = mountComposable({
+ activeCompactComment: ref({ commentId: 'c-touch' }),
+ documents: ref([]),
+ clearActiveComment,
+ resolveCommentPositionEntry: vi.fn(() => ({ entry: { bounds: { top: 100, bottom: 100 } } })),
+ });
+
+ const beforeStyle = api.compactCommentPopoverStyle.value;
+ dispatchPointerDown(anchor, { clientX: 270, clientY: 330, pointerType: 'touch' });
+ await nextTick();
+ const afterStyle = api.compactCommentPopoverStyle.value;
+
+ expect(afterStyle).toEqual(beforeStyle);
+ expect(clearActiveComment).not.toHaveBeenCalled();
+
+ document.elementsFromPoint = originalElementsFromPoint;
+ wrapper.unmount();
+ });
+
+ it('closes PDF compact popover and clears pending comment on outside pointerdown', async () => {
+ const nonAnchorTarget = document.createElement('div');
+ layers.appendChild(nonAnchorTarget);
+
+ const originalElementsFromPoint = document.elementsFromPoint;
+ document.elementsFromPoint = vi.fn(() => [nonAnchorTarget]);
+
+ const clearActiveComment = vi.fn();
+ const clearPendingComment = vi.fn();
+ const { wrapper } = mountComposable({
+ activeCompactComment: ref({ commentId: 'pdf-thread' }),
+ pendingComment: ref({ commentId: 'pdf-thread' }),
+ documents: ref([{ type: PDF }]),
+ clearActiveComment,
+ clearPendingComment,
+ });
+
+ dispatchPointerDown(nonAnchorTarget, { clientX: 120, clientY: 140, pointerType: 'mouse' });
+ await nextTick();
+
+ expect(clearPendingComment).toHaveBeenCalledTimes(1);
+ expect(clearActiveComment).toHaveBeenCalledTimes(1);
+
+ document.elementsFromPoint = originalElementsFromPoint;
+ wrapper.unmount();
+ });
+
+ it('closes PDF compact popover on touch outside-click', async () => {
+ const nonAnchorTarget = document.createElement('div');
+ layers.appendChild(nonAnchorTarget);
+
+ const originalElementsFromPoint = document.elementsFromPoint;
+ document.elementsFromPoint = vi.fn(() => [nonAnchorTarget]);
+
+ const clearActiveComment = vi.fn();
+ const { wrapper } = mountComposable({
+ activeCompactComment: ref({ commentId: 'pdf-touch' }),
+ documents: ref([{ type: PDF }]),
+ clearActiveComment,
+ });
+
+ dispatchPointerDown(nonAnchorTarget, { clientX: 140, clientY: 160, pointerType: 'touch' });
+ await nextTick();
+
+ expect(clearActiveComment).toHaveBeenCalledTimes(1);
+
+ document.elementsFromPoint = originalElementsFromPoint;
+ wrapper.unmount();
+ });
+});
diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js
index d6cdb8ef0e..d987574bb0 100644
--- a/packages/superdoc/src/core/SuperDoc.test.js
+++ b/packages/superdoc/src/core/SuperDoc.test.js
@@ -327,7 +327,6 @@ describe('SuperDoc core', () => {
it('keeps toolbarGroups separate from toolbar group item mappings', async () => {
createAppHarness();
-
const instance = new SuperDoc({
selector: '#host',
document: 'https://example.com/doc.docx',
@@ -347,6 +346,54 @@ describe('SuperDoc core', () => {
expect(instance.toolbar.config.groups).toEqual({ custom: ['bold', 'italic'] });
});
+ it('keeps valid compact comments policy fields', async () => {
+ createAppHarness();
+ const instance = new SuperDoc({
+ selector: '#host',
+ document: 'https://example.com/doc.docx',
+ documents: [],
+ modules: {
+ comments: {
+ displayMode: 'inline',
+ compactBreakpointPx: 760,
+ compactMeasurementSelector: ' #shell-main ',
+ },
+ toolbar: {},
+ },
+ onException: vi.fn(),
+ });
+ await flushMicrotasks();
+
+ expect(instance.config.modules.comments).toMatchObject({
+ displayMode: 'inline',
+ compactBreakpointPx: 760,
+ compactMeasurementSelector: '#shell-main',
+ });
+ });
+
+ it('normalizes invalid compact comments policy fields', async () => {
+ createAppHarness();
+ const instance = new SuperDoc({
+ selector: '#host',
+ document: 'https://example.com/doc.docx',
+ documents: [],
+ modules: {
+ comments: {
+ displayMode: 'unexpected-mode',
+ compactBreakpointPx: -10,
+ compactMeasurementSelector: ' ',
+ },
+ toolbar: {},
+ },
+ onException: vi.fn(),
+ });
+ await flushMicrotasks();
+
+ expect(instance.config.modules.comments.displayMode).toBeUndefined();
+ expect(instance.config.modules.comments.compactBreakpointPx).toBeUndefined();
+ expect(instance.config.modules.comments.compactMeasurementSelector).toBeUndefined();
+ });
+
it('creates a default user when none is provided', async () => {
createAppHarness();
diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts
index 8e62fb5fc3..c3e3d4c349 100644
--- a/packages/superdoc/src/core/SuperDoc.ts
+++ b/packages/superdoc/src/core/SuperDoc.ts
@@ -21,6 +21,7 @@ import { WhiteboardRenderer } from './whiteboard/WhiteboardRenderer';
import { SurfaceManager } from './surface-manager.js';
import { createDeprecatedEditorProxy } from '../helpers/deprecation.js';
import { normalizeTrackChangesConfig } from './helpers/normalize-track-changes-config.js';
+import { normalizeCommentsUiPolicy } from '../helpers/comment-small-screen.js';
const DEFAULT_USER = Object.freeze({
id: null,
@@ -538,6 +539,7 @@ export class SuperDoc extends EventEmitter {
if (!Object.prototype.hasOwnProperty.call(this.config.modules, 'comments')) {
this.config.modules.comments = {};
}
+ this.config.modules.comments = normalizeCommentsUiPolicy(this.config.modules.comments);
this.config.colors = shuffleArray(this.config.colors as `#${string}`[]);
this.userColorMap = new Map();
diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts
index cf3835b6a8..4d7b8584d0 100644
--- a/packages/superdoc/src/core/types/index.ts
+++ b/packages/superdoc/src/core/types/index.ts
@@ -1145,6 +1145,12 @@ export interface Modules {
/** Active border color for format change highlight. */
formatBorder?: string;
};
+ /** Comments/track-changes UI display policy. */
+ displayMode?: 'auto' | 'sidebar' | 'inline';
+ /** CSS selector for an explicit width measurement target in auto mode. */
+ compactMeasurementSelector?: string;
+ /** Optional fixed compact-mode breakpoint override in pixels. */
+ compactBreakpointPx?: number;
} & Record);
/** AI module configuration. */
ai?: {
diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue
index 234a9b6f87..b963af241b 100644
--- a/packages/superdoc/src/dev/components/SuperdocDev.vue
+++ b/packages/superdoc/src/dev/components/SuperdocDev.vue
@@ -733,6 +733,9 @@ const init = async () => {
// useInternalExternalComments: true,
// suppressInternalExternal: true,
permissionResolver: commentPermissionResolver,
+ displayMode: 'auto',
+ // compactMeasurementSelector: '#superdoc',
+ // compactBreakpointPx: 1400,
},
trackChanges: {
visible: true,
@@ -1633,6 +1636,12 @@ if (scrollTestMode.value) {