Skip to content

Commit 9f4d7db

Browse files
fix: hide image resize handles in view mode (#2576)
* fix: hide image resize handles in view mode * 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). * fix: clear image selection on mode change * fix: include editor in documentModeChange payload and add overlay tests - PresentationEditor now emits { editor, documentMode } matching the DocumentModeChangePayload type contract - Add ImageResizeOverlay unit tests for the isResizeDisabled guard (viewing mode, non-editable, and editing mode sanity check) --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com> Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
1 parent 76056d3 commit 9f4d7db

9 files changed

Lines changed: 323 additions & 4 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { mount } from '@vue/test-utils';
3+
import ImageResizeOverlay from './ImageResizeOverlay.vue';
4+
5+
vi.mock('@superdoc/layout-bridge', () => ({
6+
measureCache: {
7+
invalidate: vi.fn(),
8+
},
9+
}));
10+
11+
function createMockEditor(overrides = {}) {
12+
return {
13+
options: { documentMode: 'editing' },
14+
isEditable: true,
15+
view: {
16+
dom: document.createElement('div'),
17+
state: { doc: { nodeAt: vi.fn() }, tr: { setNodeMarkup: vi.fn().mockReturnThis() } },
18+
dispatch: vi.fn(),
19+
},
20+
...overrides,
21+
};
22+
}
23+
24+
describe('ImageResizeOverlay', () => {
25+
describe('isResizeDisabled guard', () => {
26+
it('should report resize disabled when documentMode is viewing', () => {
27+
const editor = createMockEditor({ options: { documentMode: 'viewing' }, isEditable: false });
28+
const imageEl = document.createElement('div');
29+
30+
const wrapper = mount(ImageResizeOverlay, {
31+
props: { editor, visible: true, imageElement: imageEl },
32+
});
33+
34+
expect(wrapper.vm.isResizeDisabled).toBe(true);
35+
});
36+
37+
it('should report resize disabled when editor is not editable', () => {
38+
const editor = createMockEditor({ isEditable: false });
39+
const imageEl = document.createElement('div');
40+
41+
const wrapper = mount(ImageResizeOverlay, {
42+
props: { editor, visible: true, imageElement: imageEl },
43+
});
44+
45+
expect(wrapper.vm.isResizeDisabled).toBe(true);
46+
});
47+
48+
it('should not report resize disabled in editing mode', () => {
49+
const editor = createMockEditor();
50+
const imageEl = document.createElement('div');
51+
52+
const wrapper = mount(ImageResizeOverlay, {
53+
props: { editor, visible: true, imageElement: imageEl },
54+
});
55+
56+
expect(wrapper.vm.isResizeDisabled).toBe(false);
57+
});
58+
});
59+
});

packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ const props = defineProps({
7272
7373
const emit = defineEmits(['resize-start', 'resize-move', 'resize-end', 'resize-success', 'resize-error']);
7474
75+
const isResizeDisabled = computed(() => props.editor?.options?.documentMode === 'viewing' || !props.editor?.isEditable);
76+
7577
/**
7678
* Parsed image metadata from data-image-metadata attribute
7779
*/
@@ -320,6 +322,8 @@ function onHandleMouseDown(event, handlePosition) {
320322
event.preventDefault();
321323
event.stopPropagation();
322324
325+
if (isResizeDisabled.value) return;
326+
323327
if (!isValidEditor(props.editor) || !imageMetadata.value || !props.imageElement) return;
324328
325329
const rect = props.imageElement.getBoundingClientRect();

packages/super-editor/src/editors/v1/components/SuperEditor.test.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const EditorConstructor = vi.hoisted(() => {
1818
});
1919
this.off = vi.fn();
2020
this.view = { focus: vi.fn() };
21+
this.setDocumentMode = vi.fn((mode) => {
22+
this.options.documentMode = mode;
23+
this.listeners.documentModeChange?.({ documentMode: mode, editor: this });
24+
});
2125
this.destroy = vi.fn();
2226
});
2327

@@ -1328,6 +1332,126 @@ describe('SuperEditor.vue', () => {
13281332
wrapper.unmount();
13291333
vi.useRealTimers();
13301334
});
1335+
1336+
it('should hide image resize overlay and skip image hover updates in viewing mode', async () => {
1337+
vi.useFakeTimers();
1338+
EditorConstructor.loadXmlData.mockResolvedValueOnce(['<docx />', {}, {}, {}]);
1339+
1340+
const wrapper = mount(SuperEditor, {
1341+
props: {
1342+
documentId: 'doc-image-view-guard',
1343+
options: {},
1344+
},
1345+
});
1346+
1347+
await flushPromises();
1348+
await flushPromises();
1349+
1350+
const updateSpy = vi.spyOn(wrapper.vm, 'updateImageResizeOverlay');
1351+
1352+
Object.defineProperty(wrapper.vm, 'activeEditor', {
1353+
value: {
1354+
value: {
1355+
options: { documentMode: 'viewing' },
1356+
isEditable: false,
1357+
view: { focus: vi.fn() },
1358+
},
1359+
},
1360+
});
1361+
wrapper.vm.getDocumentMode = () => 'viewing';
1362+
wrapper.vm.isViewingMode = () => true;
1363+
1364+
wrapper.vm.imageResizeState.visible = true;
1365+
wrapper.vm.imageResizeState.imageElement = document.createElement('div');
1366+
wrapper.vm.imageResizeState.blockId = 'image-block';
1367+
1368+
wrapper.vm.handleOverlayUpdates(new MouseEvent('mousemove'));
1369+
1370+
expect(updateSpy).not.toHaveBeenCalled();
1371+
expect(wrapper.vm.imageResizeState.visible).toBe(false);
1372+
expect(wrapper.vm.imageResizeState.imageElement).toBe(null);
1373+
expect(wrapper.vm.imageResizeState.blockId).toBe(null);
1374+
1375+
wrapper.unmount();
1376+
vi.useRealTimers();
1377+
});
1378+
1379+
it('should not apply image selection outline in viewing mode', async () => {
1380+
vi.useFakeTimers();
1381+
EditorConstructor.loadXmlData.mockResolvedValueOnce(['<docx />', {}, {}, {}]);
1382+
1383+
const wrapper = mount(SuperEditor, {
1384+
props: {
1385+
documentId: 'doc-image-selection-view-guard',
1386+
options: {},
1387+
},
1388+
});
1389+
1390+
await flushPromises();
1391+
await flushPromises();
1392+
1393+
Object.defineProperty(wrapper.vm, 'activeEditor', {
1394+
value: {
1395+
value: {
1396+
options: { documentMode: 'viewing' },
1397+
isEditable: false,
1398+
view: { focus: vi.fn() },
1399+
},
1400+
},
1401+
});
1402+
wrapper.vm.getDocumentMode = () => 'viewing';
1403+
wrapper.vm.isViewingMode = () => true;
1404+
1405+
const imageEl = document.createElement('div');
1406+
wrapper.vm.setSelectedImage(imageEl, 'image-block', 42);
1407+
1408+
expect(imageEl.classList.contains('superdoc-image-selected')).toBe(false);
1409+
expect(wrapper.vm.selectedImageState.element).toBe(null);
1410+
expect(wrapper.vm.selectedImageState.blockId).toBe(null);
1411+
expect(wrapper.vm.selectedImageState.pmStart).toBe(null);
1412+
1413+
wrapper.unmount();
1414+
vi.useRealTimers();
1415+
});
1416+
1417+
it('should clear image selection when props switch to viewing mode', async () => {
1418+
vi.useFakeTimers();
1419+
EditorConstructor.loadXmlData.mockResolvedValueOnce(['<docx />', {}, {}, {}]);
1420+
1421+
const wrapper = mount(SuperEditor, {
1422+
props: {
1423+
documentId: 'doc-image-selection-mode-switch',
1424+
options: { documentMode: 'editing' },
1425+
},
1426+
});
1427+
1428+
await flushPromises();
1429+
await flushPromises();
1430+
1431+
const imageEl = document.createElement('div');
1432+
imageEl.classList.add('superdoc-image-selected');
1433+
wrapper.vm.selectedImageState.element = imageEl;
1434+
wrapper.vm.selectedImageState.blockId = 'image-block';
1435+
wrapper.vm.selectedImageState.pmStart = 42;
1436+
wrapper.vm.imageResizeState.visible = true;
1437+
wrapper.vm.imageResizeState.imageElement = imageEl;
1438+
wrapper.vm.imageResizeState.blockId = 'image-block';
1439+
1440+
await wrapper.setProps({
1441+
options: { documentMode: 'viewing' },
1442+
});
1443+
1444+
expect(imageEl.classList.contains('superdoc-image-selected')).toBe(false);
1445+
expect(wrapper.vm.selectedImageState.element).toBe(null);
1446+
expect(wrapper.vm.selectedImageState.blockId).toBe(null);
1447+
expect(wrapper.vm.selectedImageState.pmStart).toBe(null);
1448+
expect(wrapper.vm.imageResizeState.visible).toBe(false);
1449+
expect(wrapper.vm.imageResizeState.imageElement).toBe(null);
1450+
expect(wrapper.vm.imageResizeState.blockId).toBe(null);
1451+
1452+
wrapper.unmount();
1453+
vi.useRealTimers();
1454+
});
13311455
});
13321456
});
13331457
});

packages/super-editor/src/editors/v1/components/SuperEditor.vue

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ const currentZoom = ref(1);
8989
* Stored to ensure proper removal in onBeforeUnmount to prevent memory leaks.
9090
*/
9191
let zoomChangeHandler = null;
92+
let documentModeChangeHandler = null;
9293
9394
// Watch for changes in options.rulers with deep option to catch nested changes
9495
watch(
@@ -132,6 +133,15 @@ watch(
132133
{ immediate: true },
133134
);
134135
136+
watch(
137+
() => props.options?.documentMode,
138+
(documentMode) => {
139+
if (documentMode === 'viewing') {
140+
cleanupViewingModeUi();
141+
}
142+
},
143+
);
144+
135145
/**
136146
* Computed style for the container that scales min-width based on zoom.
137147
* Uses the maximum page width across all pages (for multi-section docs with landscape pages),
@@ -321,6 +331,12 @@ const imageResizeState: ImageResizeState = reactive({
321331
blockId: null,
322332
});
323333
334+
const cleanupViewingModeUi = () => {
335+
hideTableResizeOverlay();
336+
hideImageResizeOverlay();
337+
clearSelectedImage();
338+
};
339+
324340
/**
325341
* Image selection state (for layout-engine rendered images)
326342
* @type {{element: HTMLElement | null, blockId: string | null, pmStart: number | null}}
@@ -631,6 +647,11 @@ const onTableResizeEnd = () => {
631647
const updateImageResizeOverlay = (event: MouseEvent): void => {
632648
if (!editorElem.value) return;
633649
650+
if (isViewingMode() || !activeEditor.value?.isEditable) {
651+
hideImageResizeOverlay();
652+
return;
653+
}
654+
634655
// Type guard: ensure event target is an Element
635656
if (!(event.target instanceof Element)) {
636657
imageResizeState.visible = false;
@@ -719,6 +740,11 @@ const clearSelectedImage = () => {
719740
* @returns {void}
720741
*/
721742
const setSelectedImage = (element, blockId, pmStart) => {
743+
if (isViewingMode() || !activeEditor.value?.isEditable) {
744+
clearSelectedImage();
745+
return;
746+
}
747+
722748
// Remove selection from the previously selected element
723749
if (selectedImageState.element && selectedImageState.element !== element) {
724750
selectedImageState.element.classList.remove('superdoc-image-selected');
@@ -747,10 +773,10 @@ const isViewingMode = () => getDocumentMode() === 'viewing';
747773
748774
const handleOverlayUpdates = (event) => {
749775
if (isViewingMode()) {
750-
hideTableResizeOverlay();
751-
} else {
752-
updateTableResizeOverlay(event);
776+
cleanupViewingModeUi();
777+
return;
753778
}
779+
updateTableResizeOverlay(event);
754780
// Don't evaluate image overlay during an active table resize drag —
755781
// without the oversized table overlay, pointer events can reach images
756782
// and spuriously activate the image resize overlay mid-drag.
@@ -964,6 +990,16 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } =
964990
presentationEditor: editor.value instanceof PresentationEditor ? editor.value : null,
965991
});
966992
993+
const documentModeEmitter = editor.value instanceof PresentationEditor ? editor.value : activeEditor.value;
994+
if (documentModeEmitter?.on) {
995+
documentModeChangeHandler = ({ documentMode } = {}) => {
996+
if (documentMode === 'viewing') {
997+
cleanupViewingModeUi();
998+
}
999+
};
1000+
documentModeEmitter.on('documentModeChange', documentModeChangeHandler);
1001+
}
1002+
9671003
// Attach layout-engine specific image selection listeners
9681004
if (editor.value instanceof PresentationEditor) {
9691005
const presentationEditor = editor.value;
@@ -1111,7 +1147,7 @@ const handleSuperEditorClick = (event) => {
11111147
11121148
// Update table resize overlay on click
11131149
if (isViewingMode()) {
1114-
hideTableResizeOverlay();
1150+
cleanupViewingModeUi();
11151151
} else {
11161152
updateTableResizeOverlay(event);
11171153
}
@@ -1203,6 +1239,12 @@ const handleMarginChange = ({ side, value }) => {
12031239
onBeforeUnmount(() => {
12041240
clearSelectedImage();
12051241
1242+
if (documentModeChangeHandler) {
1243+
const documentModeEmitter = editor.value instanceof PresentationEditor ? editor.value : activeEditor.value;
1244+
documentModeEmitter?.off?.('documentModeChange', documentModeChangeHandler);
1245+
documentModeChangeHandler = null;
1246+
}
1247+
12061248
// Clean up zoomChange listener if it exists
12071249
if (editor.value instanceof PresentationEditor && zoomChangeHandler) {
12081250
editor.value.off('zoomChange', zoomChangeHandler);

packages/super-editor/src/editors/v1/core/Editor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,11 @@ export class Editor extends EventEmitter<EditorEventMap> {
16201620
// This may override the setEditable calls above when read-only protection
16211621
// is enforced or when permission ranges allow editing in protected docs.
16221622
applyEffectiveEditability(this);
1623+
1624+
this.emit('documentModeChange', {
1625+
editor: this,
1626+
documentMode: cleanedMode as 'editing' | 'viewing' | 'suggesting',
1627+
});
16231628
}
16241629

16251630
/**

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,6 +1381,7 @@ export class PresentationEditor extends EventEmitter {
13811381
this.#scheduleRerender();
13821382
}
13831383
this.#updatePermissionOverlay();
1384+
this.emit('documentModeChange', { editor: this.#editor, documentMode: mode });
13841385
}
13851386

13861387
#syncDocumentModeClass() {

packages/super-editor/src/editors/v1/core/types/EditorEvents.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ export interface PaginationPayload {
5151
[key: string]: unknown;
5252
}
5353

54+
export interface DocumentModeChangePayload {
55+
editor: Editor;
56+
documentMode: 'editing' | 'viewing' | 'suggesting';
57+
}
58+
5459
/**
5560
* Payload for list definitions change
5661
*/
@@ -115,6 +120,9 @@ export interface EditorEventMap extends DefaultEventMap {
115120
/** Called when pagination updates */
116121
paginationUpdate: [PaginationPayload];
117122

123+
/** Called when document mode changes */
124+
documentModeChange: [DocumentModeChangePayload];
125+
118126
/** Called when an exception occurs */
119127
exception: [{ error: Error; editor: Editor }];
120128

Binary file not shown.

0 commit comments

Comments
 (0)