From 409269e0f88e9b3382874963ac1b63bb3f46c95e Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Thu, 26 Mar 2026 17:13:10 +0200 Subject: [PATCH 1/3] fix: hide image resize handles in view mode --- .../src/components/ImageResizeOverlay.vue | 4 + .../src/components/SuperEditor.test.js | 81 +++++++++++++++++++ .../src/components/SuperEditor.vue | 16 +++- 3 files changed, 99 insertions(+), 2 deletions(-) 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..d667034851 100644 --- a/packages/super-editor/src/components/SuperEditor.test.js +++ b/packages/super-editor/src/components/SuperEditor.test.js @@ -1328,6 +1328,87 @@ 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(); + }); }); }); }); diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index 117caeef0d..3c065d4645 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -631,6 +631,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 +724,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'); @@ -748,9 +758,10 @@ const isViewingMode = () => getDocumentMode() === 'viewing'; const handleOverlayUpdates = (event) => { if (isViewingMode()) { hideTableResizeOverlay(); - } else { - updateTableResizeOverlay(event); + hideImageResizeOverlay(); + 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. @@ -1112,6 +1123,7 @@ const handleSuperEditorClick = (event) => { // Update table resize overlay on click if (isViewingMode()) { hideTableResizeOverlay(); + clearSelectedImage(); } else { updateTableResizeOverlay(event); } From 120b129e07c62504d94c202511aeb2f802335d17 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 26 Mar 2026 15:55:25 -0300 Subject: [PATCH 2/3] test: add behavior tests for image resize in viewing mode (SD-2323) Word-native test document with embedded image and three Playwright behavior tests: overlay hidden on hover in viewing mode, selection outline blocked on click in viewing mode, and overlay still works in editing mode (sanity check). --- .../fixtures/sd-2323-image-resize-test.docx | Bin 0 -> 15848 bytes .../images/image-resize-viewing-mode.spec.ts | 76 ++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/behavior/tests/images/fixtures/sd-2323-image-resize-test.docx create mode 100644 tests/behavior/tests/images/image-resize-viewing-mode.spec.ts 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 0000000000000000000000000000000000000000..8c887d2af7aa6313fa9dfb73515ea66e25f47c0b GIT binary patch literal 15848 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 { + 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 }); + }); +}); From 9bbf2d8930c883f290e92220ae1ad1ec77b6a42c Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 27 Mar 2026 14:56:32 +0200 Subject: [PATCH 3/3] fix: clear image selection on mode change --- .../src/components/SuperEditor.test.js | 43 +++++++++++++++++++ .../src/components/SuperEditor.vue | 38 ++++++++++++++-- packages/super-editor/src/core/Editor.ts | 5 +++ .../presentation-editor/PresentationEditor.ts | 1 + .../src/core/types/EditorEvents.ts | 8 ++++ 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/components/SuperEditor.test.js b/packages/super-editor/src/components/SuperEditor.test.js index d667034851..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(); }); @@ -1409,6 +1413,45 @@ describe('SuperEditor.vue', () => { 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 3c065d4645..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}} @@ -757,8 +773,7 @@ const isViewingMode = () => getDocumentMode() === 'viewing'; const handleOverlayUpdates = (event) => { if (isViewingMode()) { - hideTableResizeOverlay(); - hideImageResizeOverlay(); + cleanupViewingModeUi(); return; } updateTableResizeOverlay(event); @@ -975,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; @@ -1122,8 +1147,7 @@ const handleSuperEditorClick = (event) => { // Update table resize overlay on click if (isViewingMode()) { - hideTableResizeOverlay(); - clearSelectedImage(); + cleanupViewingModeUi(); } else { updateTableResizeOverlay(event); } @@ -1215,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 d1e1ea96ad..1325752021 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -1597,6 +1597,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 }];