Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/super-editor/src/components/ImageResizeOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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();
Expand Down
124 changes: 124 additions & 0 deletions packages/super-editor/src/components/SuperEditor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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(['<docx />', {}, {}, {}]);

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(['<docx />', {}, {}, {}]);

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(['<docx />', {}, {}, {}]);

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();
});
});
});
});
50 changes: 46 additions & 4 deletions packages/super-editor/src/components/SuperEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1111,7 +1147,7 @@ const handleSuperEditorClick = (event) => {

// Update table resize overlay on click
if (isViewingMode()) {
hideTableResizeOverlay();
cleanupViewingModeUi();
} else {
updateTableResizeOverlay(event);
}
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1620,6 +1620,11 @@ export class Editor extends EventEmitter<EditorEventMap> {
// 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',
});
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,7 @@ export class PresentationEditor extends EventEmitter {
this.#scheduleRerender();
}
this.#updatePermissionOverlay();
this.emit('documentModeChange', { documentMode: mode });
}

#syncDocumentModeClass() {
Expand Down
8 changes: 8 additions & 0 deletions packages/super-editor/src/core/types/EditorEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export interface PaginationPayload {
[key: string]: unknown;
}

export interface DocumentModeChangePayload {
editor: Editor;
documentMode: 'editing' | 'viewing' | 'suggesting';
}

/**
* Payload for list definitions change
*/
Expand Down Expand Up @@ -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 }];

Expand Down
Binary file not shown.
76 changes: 76 additions & 0 deletions tests/behavior/tests/images/image-resize-viewing-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading