diff --git a/packages/super-editor/src/components/ImageResizeOverlay.vue b/packages/super-editor/src/components/ImageResizeOverlay.vue
index 9512f945da..03744143d2 100644
--- a/packages/super-editor/src/components/ImageResizeOverlay.vue
+++ b/packages/super-editor/src/components/ImageResizeOverlay.vue
@@ -72,6 +72,8 @@ const props = defineProps({
const emit = defineEmits(['resize-start', 'resize-move', 'resize-end', 'resize-success', 'resize-error']);
+const isResizeDisabled = computed(() => props.editor?.options?.documentMode === 'viewing' || !props.editor?.isEditable);
+
/**
* Parsed image metadata from data-image-metadata attribute
*/
@@ -320,6 +322,8 @@ function onHandleMouseDown(event, handlePosition) {
event.preventDefault();
event.stopPropagation();
+ if (isResizeDisabled.value) return;
+
if (!isValidEditor(props.editor) || !imageMetadata.value || !props.imageElement) return;
const rect = props.imageElement.getBoundingClientRect();
diff --git a/packages/super-editor/src/components/SuperEditor.test.js b/packages/super-editor/src/components/SuperEditor.test.js
index 806285c798..243aac2ea2 100644
--- a/packages/super-editor/src/components/SuperEditor.test.js
+++ b/packages/super-editor/src/components/SuperEditor.test.js
@@ -18,6 +18,10 @@ const EditorConstructor = vi.hoisted(() => {
});
this.off = vi.fn();
this.view = { focus: vi.fn() };
+ this.setDocumentMode = vi.fn((mode) => {
+ this.options.documentMode = mode;
+ this.listeners.documentModeChange?.({ documentMode: mode, editor: this });
+ });
this.destroy = vi.fn();
});
@@ -1328,6 +1332,126 @@ describe('SuperEditor.vue', () => {
wrapper.unmount();
vi.useRealTimers();
});
+
+ it('should hide image resize overlay and skip image hover updates in viewing mode', async () => {
+ vi.useFakeTimers();
+ EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]);
+
+ const wrapper = mount(SuperEditor, {
+ props: {
+ documentId: 'doc-image-view-guard',
+ options: {},
+ },
+ });
+
+ await flushPromises();
+ await flushPromises();
+
+ const updateSpy = vi.spyOn(wrapper.vm, 'updateImageResizeOverlay');
+
+ Object.defineProperty(wrapper.vm, 'activeEditor', {
+ value: {
+ value: {
+ options: { documentMode: 'viewing' },
+ isEditable: false,
+ view: { focus: vi.fn() },
+ },
+ },
+ });
+ wrapper.vm.getDocumentMode = () => 'viewing';
+ wrapper.vm.isViewingMode = () => true;
+
+ wrapper.vm.imageResizeState.visible = true;
+ wrapper.vm.imageResizeState.imageElement = document.createElement('div');
+ wrapper.vm.imageResizeState.blockId = 'image-block';
+
+ wrapper.vm.handleOverlayUpdates(new MouseEvent('mousemove'));
+
+ expect(updateSpy).not.toHaveBeenCalled();
+ expect(wrapper.vm.imageResizeState.visible).toBe(false);
+ expect(wrapper.vm.imageResizeState.imageElement).toBe(null);
+ expect(wrapper.vm.imageResizeState.blockId).toBe(null);
+
+ wrapper.unmount();
+ vi.useRealTimers();
+ });
+
+ it('should not apply image selection outline in viewing mode', async () => {
+ vi.useFakeTimers();
+ EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]);
+
+ const wrapper = mount(SuperEditor, {
+ props: {
+ documentId: 'doc-image-selection-view-guard',
+ options: {},
+ },
+ });
+
+ await flushPromises();
+ await flushPromises();
+
+ Object.defineProperty(wrapper.vm, 'activeEditor', {
+ value: {
+ value: {
+ options: { documentMode: 'viewing' },
+ isEditable: false,
+ view: { focus: vi.fn() },
+ },
+ },
+ });
+ wrapper.vm.getDocumentMode = () => 'viewing';
+ wrapper.vm.isViewingMode = () => true;
+
+ const imageEl = document.createElement('div');
+ wrapper.vm.setSelectedImage(imageEl, 'image-block', 42);
+
+ expect(imageEl.classList.contains('superdoc-image-selected')).toBe(false);
+ expect(wrapper.vm.selectedImageState.element).toBe(null);
+ expect(wrapper.vm.selectedImageState.blockId).toBe(null);
+ expect(wrapper.vm.selectedImageState.pmStart).toBe(null);
+
+ wrapper.unmount();
+ vi.useRealTimers();
+ });
+
+ it('should clear image selection when props switch to viewing mode', async () => {
+ vi.useFakeTimers();
+ EditorConstructor.loadXmlData.mockResolvedValueOnce(['', {}, {}, {}]);
+
+ const wrapper = mount(SuperEditor, {
+ props: {
+ documentId: 'doc-image-selection-mode-switch',
+ options: { documentMode: 'editing' },
+ },
+ });
+
+ await flushPromises();
+ await flushPromises();
+
+ const imageEl = document.createElement('div');
+ imageEl.classList.add('superdoc-image-selected');
+ wrapper.vm.selectedImageState.element = imageEl;
+ wrapper.vm.selectedImageState.blockId = 'image-block';
+ wrapper.vm.selectedImageState.pmStart = 42;
+ wrapper.vm.imageResizeState.visible = true;
+ wrapper.vm.imageResizeState.imageElement = imageEl;
+ wrapper.vm.imageResizeState.blockId = 'image-block';
+
+ await wrapper.setProps({
+ options: { documentMode: 'viewing' },
+ });
+
+ expect(imageEl.classList.contains('superdoc-image-selected')).toBe(false);
+ expect(wrapper.vm.selectedImageState.element).toBe(null);
+ expect(wrapper.vm.selectedImageState.blockId).toBe(null);
+ expect(wrapper.vm.selectedImageState.pmStart).toBe(null);
+ expect(wrapper.vm.imageResizeState.visible).toBe(false);
+ expect(wrapper.vm.imageResizeState.imageElement).toBe(null);
+ expect(wrapper.vm.imageResizeState.blockId).toBe(null);
+
+ wrapper.unmount();
+ vi.useRealTimers();
+ });
});
});
});
diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue
index 117caeef0d..36ae2adf4c 100644
--- a/packages/super-editor/src/components/SuperEditor.vue
+++ b/packages/super-editor/src/components/SuperEditor.vue
@@ -89,6 +89,7 @@ const currentZoom = ref(1);
* Stored to ensure proper removal in onBeforeUnmount to prevent memory leaks.
*/
let zoomChangeHandler = null;
+let documentModeChangeHandler = null;
// Watch for changes in options.rulers with deep option to catch nested changes
watch(
@@ -132,6 +133,15 @@ watch(
{ immediate: true },
);
+watch(
+ () => props.options?.documentMode,
+ (documentMode) => {
+ if (documentMode === 'viewing') {
+ cleanupViewingModeUi();
+ }
+ },
+);
+
/**
* Computed style for the container that scales min-width based on zoom.
* Uses the maximum page width across all pages (for multi-section docs with landscape pages),
@@ -321,6 +331,12 @@ const imageResizeState: ImageResizeState = reactive({
blockId: null,
});
+const cleanupViewingModeUi = () => {
+ hideTableResizeOverlay();
+ hideImageResizeOverlay();
+ clearSelectedImage();
+};
+
/**
* Image selection state (for layout-engine rendered images)
* @type {{element: HTMLElement | null, blockId: string | null, pmStart: number | null}}
@@ -631,6 +647,11 @@ const onTableResizeEnd = () => {
const updateImageResizeOverlay = (event: MouseEvent): void => {
if (!editorElem.value) return;
+ if (isViewingMode() || !activeEditor.value?.isEditable) {
+ hideImageResizeOverlay();
+ return;
+ }
+
// Type guard: ensure event target is an Element
if (!(event.target instanceof Element)) {
imageResizeState.visible = false;
@@ -719,6 +740,11 @@ const clearSelectedImage = () => {
* @returns {void}
*/
const setSelectedImage = (element, blockId, pmStart) => {
+ if (isViewingMode() || !activeEditor.value?.isEditable) {
+ clearSelectedImage();
+ return;
+ }
+
// Remove selection from the previously selected element
if (selectedImageState.element && selectedImageState.element !== element) {
selectedImageState.element.classList.remove('superdoc-image-selected');
@@ -747,10 +773,10 @@ const isViewingMode = () => getDocumentMode() === 'viewing';
const handleOverlayUpdates = (event) => {
if (isViewingMode()) {
- hideTableResizeOverlay();
- } else {
- updateTableResizeOverlay(event);
+ cleanupViewingModeUi();
+ return;
}
+ updateTableResizeOverlay(event);
// Don't evaluate image overlay during an active table resize drag —
// without the oversized table overlay, pointer events can reach images
// and spuriously activate the image resize overlay mid-drag.
@@ -964,6 +990,16 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } =
presentationEditor: editor.value instanceof PresentationEditor ? editor.value : null,
});
+ const documentModeEmitter = editor.value instanceof PresentationEditor ? editor.value : activeEditor.value;
+ if (documentModeEmitter?.on) {
+ documentModeChangeHandler = ({ documentMode } = {}) => {
+ if (documentMode === 'viewing') {
+ cleanupViewingModeUi();
+ }
+ };
+ documentModeEmitter.on('documentModeChange', documentModeChangeHandler);
+ }
+
// Attach layout-engine specific image selection listeners
if (editor.value instanceof PresentationEditor) {
const presentationEditor = editor.value;
@@ -1111,7 +1147,7 @@ const handleSuperEditorClick = (event) => {
// Update table resize overlay on click
if (isViewingMode()) {
- hideTableResizeOverlay();
+ cleanupViewingModeUi();
} else {
updateTableResizeOverlay(event);
}
@@ -1203,6 +1239,12 @@ const handleMarginChange = ({ side, value }) => {
onBeforeUnmount(() => {
clearSelectedImage();
+ if (documentModeChangeHandler) {
+ const documentModeEmitter = editor.value instanceof PresentationEditor ? editor.value : activeEditor.value;
+ documentModeEmitter?.off?.('documentModeChange', documentModeChangeHandler);
+ documentModeChangeHandler = null;
+ }
+
// Clean up zoomChange listener if it exists
if (editor.value instanceof PresentationEditor && zoomChangeHandler) {
editor.value.off('zoomChange', zoomChangeHandler);
diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts
index 0293d5d6dd..5c41af8905 100644
--- a/packages/super-editor/src/core/Editor.ts
+++ b/packages/super-editor/src/core/Editor.ts
@@ -1620,6 +1620,11 @@ export class Editor extends EventEmitter {
// This may override the setEditable calls above when read-only protection
// is enforced or when permission ranges allow editing in protected docs.
applyEffectiveEditability(this);
+
+ this.emit('documentModeChange', {
+ editor: this,
+ documentMode: cleanedMode as 'editing' | 'viewing' | 'suggesting',
+ });
}
/**
diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts
index 4d7b0854ba..ff74b8f4cd 100644
--- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts
+++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts
@@ -1366,6 +1366,7 @@ export class PresentationEditor extends EventEmitter {
this.#scheduleRerender();
}
this.#updatePermissionOverlay();
+ this.emit('documentModeChange', { documentMode: mode });
}
#syncDocumentModeClass() {
diff --git a/packages/super-editor/src/core/types/EditorEvents.ts b/packages/super-editor/src/core/types/EditorEvents.ts
index c22001d1ea..dfd9d65cf2 100644
--- a/packages/super-editor/src/core/types/EditorEvents.ts
+++ b/packages/super-editor/src/core/types/EditorEvents.ts
@@ -51,6 +51,11 @@ export interface PaginationPayload {
[key: string]: unknown;
}
+export interface DocumentModeChangePayload {
+ editor: Editor;
+ documentMode: 'editing' | 'viewing' | 'suggesting';
+}
+
/**
* Payload for list definitions change
*/
@@ -115,6 +120,9 @@ export interface EditorEventMap extends DefaultEventMap {
/** Called when pagination updates */
paginationUpdate: [PaginationPayload];
+ /** Called when document mode changes */
+ documentModeChange: [DocumentModeChangePayload];
+
/** Called when an exception occurs */
exception: [{ error: Error; editor: Editor }];
diff --git a/tests/behavior/tests/images/fixtures/sd-2323-image-resize-test.docx b/tests/behavior/tests/images/fixtures/sd-2323-image-resize-test.docx
new file mode 100644
index 0000000000..8c887d2af7
Binary files /dev/null and b/tests/behavior/tests/images/fixtures/sd-2323-image-resize-test.docx differ
diff --git a/tests/behavior/tests/images/image-resize-viewing-mode.spec.ts b/tests/behavior/tests/images/image-resize-viewing-mode.spec.ts
new file mode 100644
index 0000000000..1d53c8498a
--- /dev/null
+++ b/tests/behavior/tests/images/image-resize-viewing-mode.spec.ts
@@ -0,0 +1,76 @@
+import { test, expect } from '../../fixtures/superdoc.js';
+import path from 'node:path';
+
+/**
+ * Behavior test: image resize handles and selection outlines must be
+ * suppressed in viewing mode.
+ *
+ * Regression test for SD-2323 / IT-760.
+ */
+
+test.use({ config: { toolbar: 'full', showSelection: true } });
+
+const FIXTURE = path.resolve(import.meta.dirname, 'fixtures/sd-2323-image-resize-test.docx');
+
+test.describe('Image resize in viewing mode (SD-2323)', () => {
+ test.beforeEach(async ({ superdoc }) => {
+ await superdoc.loadDocument(FIXTURE);
+ });
+
+ test('@behavior SD-2323: image resize overlay is hidden when hovering in viewing mode', async ({ superdoc }) => {
+ // Verify image loaded
+ const imageCount = await superdoc.page.evaluate(() => {
+ const doc = (window as any).editor?.state?.doc;
+ let count = 0;
+ doc?.descendants((node: any) => {
+ if (node.type?.name === 'image') count++;
+ });
+ return count;
+ });
+ expect(imageCount).toBeGreaterThanOrEqual(1);
+
+ // Switch to viewing mode
+ await superdoc.setDocumentMode('viewing');
+ await superdoc.waitForStable();
+ await superdoc.assertDocumentMode('viewing');
+
+ // Hover over the image
+ const img = superdoc.page.locator('.superdoc-inline-image').first();
+ await expect(img).toBeAttached({ timeout: 5000 });
+ await img.hover();
+ await superdoc.waitForStable();
+
+ // The resize overlay should NOT appear
+ const overlay = superdoc.page.locator('.superdoc-image-resize-overlay');
+ await expect(overlay).toHaveCount(0);
+ });
+
+ test('@behavior SD-2323: image selection outline is not applied in viewing mode', async ({ superdoc }) => {
+ await superdoc.setDocumentMode('viewing');
+ await superdoc.waitForStable();
+ await superdoc.assertDocumentMode('viewing');
+
+ // Click on the image
+ const img = superdoc.page.locator('.superdoc-inline-image').first();
+ await expect(img).toBeAttached({ timeout: 5000 });
+ await img.click();
+ await superdoc.waitForStable();
+
+ // The image should NOT have the selection class
+ await expect(img).not.toHaveClass(/superdoc-image-selected/);
+ });
+
+ test('@behavior SD-2323: image resize overlay works normally in editing mode', async ({ superdoc }) => {
+ // Stay in editing mode (default)
+ const img = superdoc.page.locator('.superdoc-inline-image').first();
+ await expect(img).toBeAttached({ timeout: 5000 });
+
+ // Hover over the image to trigger resize overlay
+ await img.hover();
+ await superdoc.waitForStable();
+
+ // The resize overlay should appear
+ const overlay = superdoc.page.locator('.superdoc-image-resize-overlay');
+ await expect(overlay).toBeAttached({ timeout: 5000 });
+ });
+});