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 }); + }); +});