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
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,72 @@ describe('ImageResizeOverlay', () => {
expect(wrapper.vm.isResizeDisabled).toBe(false);
});
});

it('should dispatch resize transactions through the presentation editor active editor', async () => {
const bodyEditor = createMockEditor();
const headerFooterEditor = createMockEditor();
const imageNode = {
type: { name: 'image' },
attrs: { size: { width: 100, height: 50 } },
};
headerFooterEditor.view.state.doc.nodeAt.mockReturnValue(imageNode);
bodyEditor.view.state.doc.nodeAt.mockReturnValue(null);

const imageEl = document.createElement('div');
imageEl.setAttribute('data-pm-start', '0');
imageEl.setAttribute('data-sd-block-id', 'header-image');
imageEl.setAttribute(
'data-image-metadata',
JSON.stringify({
originalWidth: 100,
originalHeight: 50,
maxWidth: 500,
maxHeight: 500,
aspectRatio: 2,
minWidth: 20,
minHeight: 20,
}),
);
imageEl.getBoundingClientRect = vi.fn(() => ({
left: 10,
top: 20,
width: 100,
height: 50,
right: 110,
bottom: 70,
x: 10,
y: 20,
toJSON: () => {},
}));
document.body.appendChild(imageEl);

const presentationEditor = {
view: bodyEditor.view,
getActiveEditor: vi.fn(() => headerFooterEditor),
};

const wrapper = mount(ImageResizeOverlay, {
attachTo: document.body,
props: { editor: presentationEditor, visible: true, imageElement: imageEl },
});
await wrapper.vm.$nextTick();

const handle = wrapper.find('[data-handle-position="se"]');
await handle.trigger('mousedown', { clientX: 110, clientY: 70 });
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 }));
document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 }));

expect(headerFooterEditor.view.state.doc.nodeAt).toHaveBeenCalledWith(0);
expect(bodyEditor.view.state.doc.nodeAt).not.toHaveBeenCalled();
expect(headerFooterEditor.view.state.tr.setNodeMarkup).toHaveBeenCalledWith(
0,
null,
expect.objectContaining({ size: { width: 140, height: 70 } }),
);
expect(headerFooterEditor.view.dispatch).toHaveBeenCalledWith(headerFooterEditor.view.state.tr);
expect(bodyEditor.view.dispatch).not.toHaveBeenCalled();

wrapper.unmount();
imageEl.remove();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,15 @@ 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);
/** Header/footer edits use the presentation editor's active sub-editor. */
const resizeEditor = computed(() => {
const editor = props.editor;
return typeof editor?.getActiveEditor === 'function' ? editor.getActiveEditor() : editor;
});

const isResizeDisabled = computed(
() => resizeEditor.value?.options?.documentMode === 'viewing' || !resizeEditor.value?.isEditable,
);

/**
* Parsed image metadata from data-image-metadata attribute
Expand Down Expand Up @@ -324,7 +332,8 @@ function onHandleMouseDown(event, handlePosition) {

if (isResizeDisabled.value) return;

if (!isValidEditor(props.editor) || !imageMetadata.value || !props.imageElement) return;
const editor = resizeEditor.value;
if (!isValidEditor(editor) || !imageMetadata.value || !props.imageElement) return;

const rect = props.imageElement.getBoundingClientRect();

Expand All @@ -341,7 +350,7 @@ function onHandleMouseDown(event, handlePosition) {
};

// Disable pointer events on PM view to prevent conflicts
const pmView = props.editor.view.dom;
const pmView = editor.view.dom;
pmView.style.pointerEvents = 'none';

// Add global listeners
Expand Down Expand Up @@ -486,8 +495,9 @@ function onDocumentMouseUp(event) {
document.removeEventListener('mouseup', onDocumentMouseUp);
document.removeEventListener('keydown', onEscapeKey);

if (props.editor?.view) {
const pmView = props.editor.view.dom;
const editor = resizeEditor.value;
if (editor?.view) {
const pmView = editor.view.dom;
if (pmView && pmView.style) {
pmView.style.pointerEvents = 'auto';
}
Expand Down Expand Up @@ -525,7 +535,8 @@ function onDocumentMouseUp(event) {
* @param {number} newHeight - New height in pixels
*/
function dispatchResizeTransaction(blockId, newWidth, newHeight) {
if (!isValidEditor(props.editor) || !props.imageElement) {
const editor = resizeEditor.value;
if (!isValidEditor(editor) || !props.imageElement) {
return;
}

Expand All @@ -539,7 +550,7 @@ function dispatchResizeTransaction(blockId, newWidth, newHeight) {
}

try {
const { state, dispatch } = props.editor.view;
const { state, dispatch } = editor.view;
const tr = state.tr;

// Find image position using data-pm-start attribute
Expand Down Expand Up @@ -644,8 +655,9 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', onEscapeKey);

// Re-enable PM pointer events
if (props.editor?.view?.dom) {
props.editor.view.dom.style.pointerEvents = 'auto';
const editor = resizeEditor.value;
if (editor?.view?.dom) {
editor.view.dom.style.pointerEvents = 'auto';
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1346,7 +1346,7 @@ onBeforeUnmount(() => {
<!-- Image resize overlay for interactive image resizing -->
<ImageResizeOverlay
v-if="editorReady && activeEditor"
:editor="activeEditor"
:editor="contextMenuEditor"
:visible="imageResizeState.visible"
:imageElement="imageResizeState.imageElement"
/>
Expand Down
9 changes: 7 additions & 2 deletions packages/super-editor/src/editors/v1/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2543,7 +2543,12 @@ export class Editor extends EventEmitter<EditorEventMap> {
* the cursor is inside a table cell, in which case the cell width is returned.
*/
getMaxContentSize(): { width?: number; height?: number } {
if (!this.converter) return {};
const localPageStyles = this.converter?.pageStyles;
const parentPageStyles = this.options.parentEditor?.converter?.pageStyles;
const localPageSize = localPageStyles?.pageSize;
const pageStyles =
localPageSize?.width && localPageSize?.height ? localPageStyles : (parentPageStyles ?? localPageStyles);
if (!pageStyles) return {};

// When the cursor is inside a table cell, constrain width to the cell's content
// width so images inserted into a cell are never wider than that cell.
Expand All @@ -2569,7 +2574,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
return {};
}

const { pageSize = {}, pageMargins = {} } = this.converter.pageStyles ?? {};
const { pageSize = {}, pageMargins = {} } = pageStyles;
const { width, height } = pageSize;

if (!width || !height) return {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,39 @@ describe('Editor Web Layout Mode', () => {
expect(size.width).toBe(expectedWidth);
});

it('uses parent editor page styles for header/footer story editors', () => {
const editor = {
converter: { pageStyles: {} },
options: {
viewOptions: { layout: 'print' },
isHeaderOrFooter: true,
parentEditor: {
converter: {
pageStyles: {
pageSize: { width: 8.5, height: 11 },
pageMargins: { top: 1, bottom: 1, left: 1, right: 1 },
},
},
},
},
state: {
selection: {
$head: {
depth: 1,
node: () => ({ type: { name: 'paragraph' }, attrs: {} }),
},
},
},
isWebLayout() {
return false;
},
};

const size = Editor.prototype.getMaxContentSize.call(editor);

expect(size).toEqual({ width: 604, height: 814 });
});

it('falls back to page content width when colwidth is empty', () => {
const PIXELS_PER_INCH = 96;
const MAX_WIDTH_BUFFER_PX = 20;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Normalize OOXML part targets (e.g. `header1.xml`, `word/header1.xml`) to `word/...` paths.
*/
export function normalizeWordPartPath(target = '') {
const normalized = String(target)
.replace(/\\/g, '/')
.replace(/^(\.\/|\/)+/, '')
.replace(/^word\//, '');

return `word/${normalized}`;
}

/**
* Resolve the `.rels` part path for a given OOXML part path per OPC rules:
* `word/header1.xml` → `word/_rels/header1.xml.rels`
* `word/headers/header1.xml` → `word/headers/_rels/header1.xml.rels`
*/
export function getWordPartRelsPath(partPath = '') {
const normalized = String(partPath).replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');

if (lastSlash < 0 || lastSlash === normalized.length - 1) {
const fileName = lastSlash < 0 ? normalized : normalized.slice(lastSlash + 1);
return `word/_rels/${fileName}.rels`;
}

const directory = normalized.slice(0, lastSlash);
const fileName = normalized.slice(lastSlash + 1);
return `${directory}/_rels/${fileName}.rels`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { getWordPartRelsPath, normalizeWordPartPath } from './word-part-path.js';

describe('word-part-path', () => {
describe('normalizeWordPartPath', () => {
it('normalizes flat and prefixed targets', () => {
expect(normalizeWordPartPath('header1.xml')).toBe('word/header1.xml');
expect(normalizeWordPartPath('word/header1.xml')).toBe('word/header1.xml');
expect(normalizeWordPartPath('headers/header1.xml')).toBe('word/headers/header1.xml');
});
});

describe('getWordPartRelsPath', () => {
it('places rels beside flat word parts', () => {
expect(getWordPartRelsPath('word/header1.xml')).toBe('word/_rels/header1.xml.rels');
expect(getWordPartRelsPath('word/footer2.xml')).toBe('word/_rels/footer2.xml.rels');
});

it('places rels beside nested word parts', () => {
expect(getWordPartRelsPath('word/headers/header1.xml')).toBe('word/headers/_rels/header1.xml.rels');
expect(getWordPartRelsPath('word/customXml/item1.xml')).toBe('word/customXml/_rels/item1.xml.rels');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
isHeaderFooterPartId,
SOURCE_HEADER_FOOTER_LOCAL,
} from './header-footer-part-descriptor.js';
import { getWordPartRelsPath, normalizeWordPartPath } from '../../helpers/word-part-path.js';

// ---------------------------------------------------------------------------
// Converter shape
Expand Down Expand Up @@ -44,6 +45,7 @@ interface ExportToXmlJsonOpts {
comments?: unknown[];
commentDefinitions?: unknown[];
isFinalDoc?: boolean;
existingRelationships?: unknown[];
}

interface XmlJsonDoc {
Expand Down Expand Up @@ -71,6 +73,19 @@ interface XmlElement {
const HEADER_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header';
const FOOTER_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer';

function normalizeHeaderFooterPartPath(target = ''): PartId {
return normalizeWordPartPath(target) as PartId;
}

function getHeaderFooterRelsPartId(partId: PartId): PartId {
return getWordPartRelsPath(partId) as PartId;
}

function getRelationshipElements(part: unknown): unknown[] {
const relsPart = part as XmlElement | undefined;
return relsPart?.elements?.find((el) => el.name === 'Relationships')?.elements ?? [];
}

/**
* Resolve a header/footer relationship ID (e.g., 'rId7') to its OOXML part path
* (e.g., 'word/header1.xml').
Expand All @@ -91,12 +106,17 @@ export function resolvePartIdFromRefId(editor: Editor, headerFooterRefId: string
const target = el.attributes?.Target;
if (!target) continue;

return `word/${target}` as PartId;
return normalizeHeaderFooterPartPath(target);
}

return null;
}

export function resolveHeaderFooterRelsPartIdFromRefId(editor: Editor, headerFooterRefId: string): PartId | null {
const partId = resolvePartIdFromRefId(editor, headerFooterRefId);
return partId ? getHeaderFooterRelsPartId(partId) : null;
}

/** @deprecated Use `resolvePartIdFromRefId` — alias kept for backward compatibility. */
export const resolvePartIdFromSectionId = resolvePartIdFromRefId;

Expand Down Expand Up @@ -172,6 +192,8 @@ export function exportSubEditorToPart(

// Ensure descriptor is registered for this dynamic part
ensureHeaderFooterDescriptor(partId, headerFooterRefId);
const relsPartId = getHeaderFooterRelsPartId(partId);
const existingRelationships = getRelationshipElements(converter.convertedXml?.[relsPartId]);

// Get current PM JSON from the sub-editor
const pmJson =
Expand All @@ -191,6 +213,7 @@ export function exportSubEditorToPart(
isHeaderFooter: true,
comments: [],
commentDefinitions: [],
existingRelationships,
});
bodyContent = result?.elements?.[0]?.elements ?? [];
} catch (err) {
Expand Down Expand Up @@ -273,7 +296,7 @@ export function registerExistingHeaderFooterDescriptors(editor: Editor): void {
const id = el.attributes?.Id;
if (!target || !id) continue;

const partId = `word/${target}` as PartId;
const partId = normalizeHeaderFooterPartPath(target);
if (isHeaderFooterPartId(partId)) {
ensureHeaderFooterDescriptor(partId, id);
}
Expand Down
Loading
Loading