diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js
index 73838b7f86..f2f2f14dda 100644
--- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js
+++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js
@@ -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();
+ });
});
diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue
index 03744143d2..a4293db5d8 100644
--- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue
+++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue
@@ -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
@@ -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();
@@ -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
@@ -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';
}
@@ -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;
}
@@ -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
@@ -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';
}
}
});
diff --git a/packages/super-editor/src/editors/v1/components/SuperEditor.vue b/packages/super-editor/src/editors/v1/components/SuperEditor.vue
index f274818449..9ef3822539 100644
--- a/packages/super-editor/src/editors/v1/components/SuperEditor.vue
+++ b/packages/super-editor/src/editors/v1/components/SuperEditor.vue
@@ -1346,7 +1346,7 @@ onBeforeUnmount(() => {
diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts
index df697952dc..8723401b75 100644
--- a/packages/super-editor/src/editors/v1/core/Editor.ts
+++ b/packages/super-editor/src/editors/v1/core/Editor.ts
@@ -2543,7 +2543,12 @@ export class Editor extends EventEmitter {
* 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.
@@ -2569,7 +2574,7 @@ export class Editor extends EventEmitter {
return {};
}
- const { pageSize = {}, pageMargins = {} } = this.converter.pageStyles ?? {};
+ const { pageSize = {}, pageMargins = {} } = pageStyles;
const { width, height } = pageSize;
if (!width || !height) return {};
diff --git a/packages/super-editor/src/editors/v1/core/Editor.webLayout.test.ts b/packages/super-editor/src/editors/v1/core/Editor.webLayout.test.ts
index 6526146f08..17cd495e1b 100644
--- a/packages/super-editor/src/editors/v1/core/Editor.webLayout.test.ts
+++ b/packages/super-editor/src/editors/v1/core/Editor.webLayout.test.ts
@@ -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;
diff --git a/packages/super-editor/src/editors/v1/core/helpers/word-part-path.js b/packages/super-editor/src/editors/v1/core/helpers/word-part-path.js
new file mode 100644
index 0000000000..bf73cff871
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/helpers/word-part-path.js
@@ -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`;
+}
diff --git a/packages/super-editor/src/editors/v1/core/helpers/word-part-path.test.js b/packages/super-editor/src/editors/v1/core/helpers/word-part-path.test.js
new file mode 100644
index 0000000000..4ed3259a1e
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/core/helpers/word-part-path.test.js
@@ -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');
+ });
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-sync.ts b/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-sync.ts
index a2592c21b6..efe970916d 100644
--- a/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-sync.ts
+++ b/packages/super-editor/src/editors/v1/core/parts/adapters/header-footer-sync.ts
@@ -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
@@ -44,6 +45,7 @@ interface ExportToXmlJsonOpts {
comments?: unknown[];
commentDefinitions?: unknown[];
isFinalDoc?: boolean;
+ existingRelationships?: unknown[];
}
interface XmlJsonDoc {
@@ -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').
@@ -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;
@@ -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 =
@@ -191,6 +213,7 @@ export function exportSubEditorToPart(
isHeaderFooter: true,
comments: [],
commentDefinitions: [],
+ existingRelationships,
});
bodyContent = result?.elements?.[0]?.elements ?? [];
} catch (err) {
@@ -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);
}
diff --git a/packages/super-editor/src/editors/v1/core/parts/adapters/relationships-mutation.ts b/packages/super-editor/src/editors/v1/core/parts/adapters/relationships-mutation.ts
index dc11540355..9961a072b6 100644
--- a/packages/super-editor/src/editors/v1/core/parts/adapters/relationships-mutation.ts
+++ b/packages/super-editor/src/editors/v1/core/parts/adapters/relationships-mutation.ts
@@ -11,10 +11,13 @@
*/
import type { Editor } from '../../Editor.js';
+import type { PartId } from '../types.js';
import { mutatePart } from '../mutation/mutate-part.js';
+import { hasPart } from '../store/part-store.js';
import { RELATIONSHIP_TYPES } from '../../super-converter/docx-helpers/docx-constants.js';
const RELS_PART_ID = 'word/_rels/document.xml.rels' as const;
+const RELS_XMLNS = 'http://schemas.openxmlformats.org/package/2006/relationships';
// ---------------------------------------------------------------------------
// Internal helpers
@@ -27,7 +30,9 @@ interface RelElement {
}
interface RelsXml {
- elements?: Array<{ name: string; elements?: RelElement[] }>;
+ type?: string;
+ name?: string;
+ elements?: Array<{ type?: string; name: string; attributes?: Record; elements?: RelElement[] }>;
}
function getRelationshipsTag(part: RelsXml): { name: string; elements: RelElement[] } | undefined {
@@ -52,6 +57,47 @@ function getMaxIdInt(elements: RelElement[]): number {
return max;
}
+function createRelationshipElement(id: string, mappedType: string, target: string, isExternal: boolean): RelElement {
+ const rel: RelElement = {
+ type: 'element',
+ name: 'Relationship',
+ attributes: {
+ Id: id,
+ Type: mappedType,
+ Target: target,
+ },
+ };
+
+ if (isExternal) {
+ rel.attributes.TargetMode = 'External';
+ }
+
+ return rel;
+}
+
+function createRelationshipsPart(elements: RelElement[] = []): RelsXml {
+ return {
+ type: 'element',
+ name: 'document',
+ elements: [
+ {
+ type: 'element',
+ name: 'Relationships',
+ attributes: { xmlns: RELS_XMLNS },
+ elements,
+ },
+ ],
+ };
+}
+
+function findExistingRelationship(elements: RelElement[], target: string, normalized: string, mappedType: string) {
+ return elements.find(
+ (rel) =>
+ (rel.attributes?.Target === normalized || rel.attributes?.Target === target) &&
+ rel.attributes?.Type === mappedType,
+ );
+}
+
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -59,6 +105,7 @@ function getMaxIdInt(elements: RelElement[]): number {
export interface FindOrCreateOptions {
target: string;
type: string;
+ partId?: PartId;
dryRun?: boolean;
expectedRevision?: string;
}
@@ -69,7 +116,7 @@ export interface FindOrCreateOptions {
* Returns the rId string, or null on failure.
*/
export function findOrCreateRelationship(editor: Editor, source: string, options: FindOrCreateOptions): string | null {
- const { target, type, dryRun, expectedRevision } = options;
+ const { target, type, partId = RELS_PART_ID, dryRun, expectedRevision } = options;
if (!target || typeof target !== 'string') return null;
if (!type || typeof type !== 'string') return null;
@@ -83,9 +130,24 @@ export function findOrCreateRelationship(editor: Editor, source: string, options
const normalized = normalizeTarget(target);
const isExternal = type === 'hyperlink';
+ if (!hasPart(editor, partId)) {
+ const newId = 'rId1';
+ const targetValue = isExternal ? target : normalized;
+ mutatePart({
+ editor,
+ partId,
+ operation: 'create',
+ source,
+ dryRun,
+ expectedRevision,
+ initial: createRelationshipsPart([createRelationshipElement(newId, mappedType, targetValue, isExternal)]),
+ });
+ return newId;
+ }
+
const result = mutatePart({
editor,
- partId: RELS_PART_ID,
+ partId,
operation: 'mutate',
source,
dryRun,
@@ -95,35 +157,14 @@ export function findOrCreateRelationship(editor: Editor, source: string, options
if (!tag) return null;
// Reuse-by-target: if relationship already exists, return its rId
- const existing = tag.elements.find(
- (rel) => rel.attributes?.Target === normalized && rel.attributes?.Type === mappedType,
- );
+ const existing = findExistingRelationship(tag.elements, target, normalized, mappedType);
if (existing) return existing.attributes.Id;
- // Also check for the un-normalized target (backward compat)
- const existingRaw = tag.elements.find(
- (rel) => rel.attributes?.Target === target && rel.attributes?.Type === mappedType,
- );
- if (existingRaw) return existingRaw.attributes.Id;
-
// Allocate new rId
const newIdInt = getMaxIdInt(tag.elements) + 1;
const newId = `rId${newIdInt}`;
- const newRel: RelElement = {
- type: 'element',
- name: 'Relationship',
- attributes: {
- Id: newId,
- Type: mappedType,
- Target: isExternal ? target : normalized,
- },
- };
-
- if (isExternal) {
- newRel.attributes.TargetMode = 'External';
- }
-
+ const newRel = createRelationshipElement(newId, mappedType, isExternal ? target : normalized, isExternal);
tag.elements.push(newRel);
return newId;
},
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts
index 1ed2504937..19d9698782 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.test.ts
@@ -87,4 +87,12 @@ describe('ensureEditorMovableObjectInteractionStyles', () => {
expect(css).toContain('cursor: grab');
expect(css).toContain('user-select: none');
});
+
+ it('keeps header and footer images targetable for resize hover', () => {
+ ensureEditorMovableObjectInteractionStyles(document);
+ const css = document.querySelector('[data-superdoc-editor-movable-object-interaction-styles]')?.textContent ?? '';
+ expect(css).toContain('.superdoc-page-header .superdoc-image-fragment');
+ expect(css).toContain('.superdoc-page-footer .superdoc-inline-image');
+ expect(css).toContain('pointer-events: auto');
+ });
});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts
index e41d03046a..33c0b230f4 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EditorStyleInjector.ts
@@ -111,6 +111,16 @@ const MOVABLE_OBJECT_INTERACTION_STYLES = `
cursor: grabbing;
}
+/* Header/footer decoration containers are pointer-events:none; keep images targetable for hover/resize. */
+.superdoc-layout .superdoc-page-header .superdoc-image-fragment,
+.superdoc-layout .superdoc-page-footer .superdoc-image-fragment,
+.superdoc-layout .superdoc-page-header .superdoc-inline-image-clip-wrapper,
+.superdoc-layout .superdoc-page-footer .superdoc-inline-image-clip-wrapper,
+.superdoc-layout .superdoc-page-header .superdoc-inline-image,
+.superdoc-layout .superdoc-page-footer .superdoc-inline-image {
+ pointer-events: auto;
+}
+
/* Keep the active drag source from selecting text while dragging */
.superdoc-layout .superdoc-structured-content__label,
.superdoc-layout .superdoc-structured-content-inline__label,
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js
index 4e453eb2de..4d672d9e00 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js
@@ -27,6 +27,7 @@ import { ensureSettingsRoot, hasUpdateFields, setUpdateFields } from '../../docu
import { importFootnoteData, importEndnoteData } from './v2/importer/documentFootnotesImporter.js';
import { DocxHelpers } from './docx-helpers/index.js';
import { mergeRelationshipElements } from './relationship-helpers.js';
+import { getWordPartRelsPath, normalizeWordPartPath } from '../helpers/word-part-path.js';
import { COMMENT_RELATIONSHIP_TYPES } from './constants.js';
import {
createEmptyBibliographyPart,
@@ -1306,6 +1307,7 @@ class SuperConverter {
fieldsHighlightColor = null,
preserveSdtWrappers = false,
statFieldCacheMap = undefined,
+ existingRelationships = [],
}) {
const bodyNode = this.savedTagsToRestore.find((el) => el.name === 'w:body');
@@ -1342,6 +1344,7 @@ class SuperConverter {
fieldsHighlightColor,
preserveSdtWrappers,
statFieldCacheMap: resolvedCacheMap,
+ existingRelationships,
});
return { result, params };
@@ -1479,9 +1482,13 @@ class SuperConverter {
const newDocRels = [];
Object.entries(this.headers).forEach(([id, header], index) => {
- const fileName =
+ const relationshipTarget =
relationships.elements.find((el) => el.attributes.Id === id)?.attributes.Target || `header${index + 1}.xml`;
+ const partPath = normalizeWordPartPath(relationshipTarget);
+ const relsPath = getWordPartRelsPath(partPath);
const headerEditor = this.headerEditors.find((item) => item.id === id);
+ const existingRelationships =
+ this.convertedXml[relsPath]?.elements?.find((x) => x.name === 'Relationships')?.elements || [];
if (!headerEditor) return;
@@ -1493,13 +1500,14 @@ class SuperConverter {
commentDefinitions: [],
isHeaderFooter: true,
isFinalDoc,
+ existingRelationships,
});
const bodyContent = result.elements[0].elements;
- const file = this.convertedXml[`word/${fileName}`];
+ const file = this.convertedXml[partPath];
if (!file) {
- this.convertedXml[`word/${fileName}`] = {
+ this.convertedXml[partPath] = {
declaration: this.initialJSON?.declaration,
elements: [
{
@@ -1516,18 +1524,15 @@ class SuperConverter {
attributes: {
Id: id,
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header',
- Target: fileName,
+ Target: partPath.replace(/^word\//, ''),
},
});
}
- this.convertedXml[`word/${fileName}`].elements[0].elements = bodyContent;
+ this.convertedXml[partPath].elements[0].elements = bodyContent;
if (params.relationships.length) {
- const relationships =
- this.convertedXml[`word/_rels/${fileName}.rels`]?.elements?.find((x) => x.name === 'Relationships')
- ?.elements || [];
- this.convertedXml[`word/_rels/${fileName}.rels`] = {
+ this.convertedXml[relsPath] = {
declaration: this.initialJSON?.declaration,
elements: [
{
@@ -1535,7 +1540,7 @@ class SuperConverter {
attributes: {
xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships',
},
- elements: [...relationships, ...params.relationships],
+ elements: mergeRelationshipElements(existingRelationships, params.relationships),
},
],
};
@@ -1543,9 +1548,13 @@ class SuperConverter {
});
Object.entries(this.footers).forEach(([id, footer], index) => {
- const fileName =
+ const relationshipTarget =
relationships.elements.find((el) => el.attributes.Id === id)?.attributes.Target || `footer${index + 1}.xml`;
+ const partPath = normalizeWordPartPath(relationshipTarget);
+ const relsPath = getWordPartRelsPath(partPath);
const footerEditor = this.footerEditors.find((item) => item.id === id);
+ const existingRelationships =
+ this.convertedXml[relsPath]?.elements?.find((x) => x.name === 'Relationships')?.elements || [];
if (!footerEditor) return;
@@ -1557,13 +1566,14 @@ class SuperConverter {
commentDefinitions: [],
isHeaderFooter: true,
isFinalDoc,
+ existingRelationships,
});
const bodyContent = result.elements[0].elements;
- const file = this.convertedXml[`word/${fileName}`];
+ const file = this.convertedXml[partPath];
if (!file) {
- this.convertedXml[`word/${fileName}`] = {
+ this.convertedXml[partPath] = {
declaration: this.initialJSON?.declaration,
elements: [
{
@@ -1580,18 +1590,15 @@ class SuperConverter {
attributes: {
Id: id,
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer',
- Target: fileName,
+ Target: partPath.replace(/^word\//, ''),
},
});
}
- this.convertedXml[`word/${fileName}`].elements[0].elements = bodyContent;
+ this.convertedXml[partPath].elements[0].elements = bodyContent;
if (params.relationships.length) {
- const relationships =
- this.convertedXml[`word/_rels/${fileName}.rels`]?.elements?.find((x) => x.name === 'Relationships')
- ?.elements || [];
- this.convertedXml[`word/_rels/${fileName}.rels`] = {
+ this.convertedXml[relsPath] = {
declaration: this.initialJSON?.declaration,
elements: [
{
@@ -1599,7 +1606,7 @@ class SuperConverter {
attributes: {
xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships',
},
- elements: [...relationships, ...params.relationships],
+ elements: mergeRelationshipElements(existingRelationships, params.relationships),
},
],
};
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js
index 9fadc9959e..0f0e4d3b66 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js
@@ -8,6 +8,7 @@ import { readImageDimensionsFromDataUri } from '@converter/image-dimensions.js';
const DECORATIVE_EXT_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}';
const DECORATIVE_NAMESPACE = 'http://schemas.microsoft.com/office/drawing/2017/decorative';
const HYPERLINK_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink';
+const IMAGE_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image';
/**
* Resolve the hyperlink relationship rId for an image, if applicable.
@@ -217,12 +218,16 @@ export const translateImageNode = (params) => {
}
if (imageId) {
- const docx = params.converter?.convertedXml || {};
- const rels = docx['word/_rels/document.xml.rels'];
- const relsTag = rels?.elements?.find((el) => el.name === 'Relationships');
- const hasRelation = relsTag?.elements.find((el) => el.attributes.Id === imageId);
const path = src?.split('word/')[1];
- if (!hasRelation) {
+ const relationships = params.isHeaderFooter ? params.existingRelationships : getDocumentRelationships(params);
+ const existingRelation = findImageRelationship(relationships, {
+ id: imageId,
+ target: path,
+ });
+
+ if (existingRelation) {
+ imageId = existingRelation.attributes.Id;
+ } else {
addImageRelationshipForId(params, imageId, path);
}
} else if (params.node.type === 'image' && !imageId) {
@@ -500,13 +505,26 @@ function addImageRelationshipForId(params, id, imagePath) {
name: 'Relationship',
attributes: {
Id: id,
- Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
+ Type: IMAGE_REL_TYPE,
Target: imagePath,
},
};
params.relationships.push(newRel);
}
+function getDocumentRelationships(params) {
+ const docx = params.converter?.convertedXml || {};
+ const rels = docx['word/_rels/document.xml.rels'];
+ return rels?.elements?.find((el) => el.name === 'Relationships')?.elements ?? [];
+}
+
+function findImageRelationship(relationships = [], { id, target }) {
+ return relationships.find((rel) => {
+ if (rel?.attributes?.Type !== IMAGE_REL_TYPE) return false;
+ return rel.attributes.Id === id || rel.attributes.Target === target;
+ });
+}
+
/**
* Translates a vectorShape node back to XML.
* @param {Object} params - Translation parameters
diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js
index f6ce209acf..e87b1cd72d 100644
--- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js
+++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js
@@ -130,6 +130,32 @@ describe('translateImageNode', () => {
expect(baseParams.relationships[0].attributes.Id).toBe('rId123');
});
+ it('should reuse header/footer existingRelationships for image rIds', () => {
+ baseParams.isHeaderFooter = true;
+ baseParams.node.attrs.rId = 'rIdHeaderImage';
+ baseParams.existingRelationships = [
+ {
+ type: 'element',
+ name: 'Relationship',
+ attributes: {
+ Id: 'rIdHeaderImage',
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
+ Target: 'media/test.png',
+ },
+ },
+ ];
+
+ const result = translateImageNode(baseParams);
+
+ const blip = result.elements
+ .find((e) => e.name === 'a:graphic')
+ .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill')
+ .elements.find((e) => e.name === 'a:blip');
+
+ expect(blip.attributes['r:embed']).toBe('rIdHeaderImage');
+ expect(baseParams.relationships).toHaveLength(0);
+ });
+
it('should call prepareTextAnnotation for fieldAnnotation without type', () => {
const params = {
...baseParams,
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/header-footers-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/header-footers-adapter.ts
index 24fc511910..ecb12915f7 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/header-footers-adapter.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/header-footers-adapter.ts
@@ -22,6 +22,7 @@ import type {
} from '@superdoc/document-api';
import { buildResolvedHandle, buildDiscoveryItem, buildDiscoveryResult } from '@superdoc/document-api';
import type { Editor } from '../core/Editor.js';
+import { getWordPartRelsPath } from '../core/helpers/word-part-path.js';
import { DocumentApiAdapterError } from './errors.js';
import { getRevision, checkRevision } from './plan-engine/revision-tracker.js';
import { resolveSectionProjections, type SectionProjection } from './helpers/sections-resolver.js';
@@ -554,10 +555,7 @@ export function headerFootersPartsDeleteAdapter(
delete convertedXml[partPath];
// 5. Remove rels for the part
- const partFileName = partPath.split('/').pop();
- if (partFileName) {
- delete convertedXml[`word/_rels/${partFileName}.rels`];
- }
+ delete convertedXml[getWordPartRelsPath(partPath)];
// 6. Remove JSON collection entry
const collection = kind === 'header' ? converter.headers : converter.footers;
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-parts.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-parts.ts
index b6bdb0f456..cdbb507161 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-parts.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-parts.ts
@@ -6,6 +6,7 @@ import { compoundMutation } from '../../core/parts/mutation/compound-mutation.js
import { registerHeaderFooterInvalidation } from '../../core/parts/invalidation/invalidation-handlers.js';
import { removePart, hasPart } from '../../core/parts/store/part-store.js';
import type { XmlElement } from './sections-xml.js';
+import { getWordPartRelsPath } from '../../core/helpers/word-part-path.js';
const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels';
const RELS_XMLNS = 'http://schemas.openxmlformats.org/package/2006/relationships';
@@ -98,10 +99,7 @@ function normalizeRelationshipTarget(target: string): string {
}
function toRelsPathForPart(partPath: string): string {
- const normalized = normalizeRelationshipTarget(partPath);
- const fileName = normalized.split('/').pop();
- if (!fileName) return normalized;
- return `word/_rels/${fileName}.rels`;
+ return getWordPartRelsPath(normalizeRelationshipTarget(partPath));
}
function ensureConvertedXml(converter: ConverterWithHeaderFooterParts): Record {
diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-slot-materialization.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-slot-materialization.ts
index e3a527e9c5..70e128df98 100644
--- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-slot-materialization.ts
+++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/header-footer-slot-materialization.ts
@@ -12,6 +12,7 @@
import type { SectionHeaderFooterKind, SectionHeaderFooterVariant } from '@superdoc/document-api';
import type { Editor } from '../../core/Editor.js';
+import { getWordPartRelsPath } from '../../core/helpers/word-part-path.js';
import type { SectionProjection } from './sections-resolver.js';
import { resolveSectionProjections } from './sections-resolver.js';
import { readTargetSectPr } from './section-projection-access.js';
@@ -64,7 +65,7 @@ function cleanupCreatedPart(editor: Editor, partPath: string): void {
const partId = partPath as PartId;
if (hasPart(editor, partId)) removePart(editor, partId);
removeInvalidationHandler(partId);
- const relsPath = `word/_rels/${partPath.split('/').pop()}.rels` as PartId;
+ const relsPath = getWordPartRelsPath(partPath) as PartId;
if (hasPart(editor, relsPath)) removePart(editor, relsPath);
}
diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/part-paths.ts b/packages/super-editor/src/editors/v1/extensions/diffing/part-paths.ts
index 5b9f68a768..e6bc99cca5 100644
--- a/packages/super-editor/src/editors/v1/extensions/diffing/part-paths.ts
+++ b/packages/super-editor/src/editors/v1/extensions/diffing/part-paths.ts
@@ -1,3 +1,5 @@
+import { getWordPartRelsPath } from '../../core/helpers/word-part-path.js';
+
const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels';
export function toRelsPathForPart(partPath: string): string | null {
@@ -5,12 +7,9 @@ export function toRelsPathForPart(partPath: string): string | null {
return null;
}
- const lastSlash = partPath.lastIndexOf('/');
- if (lastSlash < 0 || lastSlash === partPath.length - 1) {
+ if (!partPath.includes('/') || partPath.endsWith('/')) {
return null;
}
- const directory = partPath.slice(0, lastSlash);
- const fileName = partPath.slice(lastSlash + 1);
- return `${directory}/_rels/${fileName}.rels`;
+ return getWordPartRelsPath(partPath);
}
diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js
index 7072d07d32..814c9d75d9 100644
--- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js
+++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.js
@@ -4,6 +4,7 @@ import { processUploadedImage } from './processUploadedImage.js';
import { buildMediaPath, ensureUniqueFileName } from './fileNameUtils.js';
import { generateDocxRandomId } from '@core/helpers/index.js';
import { findOrCreateRelationship } from '@core/parts/adapters/relationships-mutation.js';
+import { resolveHeaderFooterRelsPartIdFromRefId } from '@core/parts/adapters/header-footer-sync.js';
const fileTooLarge = (file) => {
let fileSizeMb = Number((file.size / (1024 * 1024)).toFixed(4));
@@ -65,6 +66,34 @@ export const generateUniqueDocPrId = (editor) => {
return candidate;
};
+function getImageMediaStores(editor) {
+ const own = editor?.storage?.image?.media;
+ const parent = editor?.options?.parentEditor?.storage?.image?.media;
+ const stores = [];
+ if (own) stores.push(own);
+ if (parent && parent !== own) stores.push(parent);
+ return stores;
+}
+
+function getExistingImageFileNames(editor) {
+ const names = new Set();
+ for (const media of getImageMediaStores(editor)) {
+ Object.keys(media).forEach((key) => names.add(key.split('/').pop()));
+ }
+ return names;
+}
+
+function registerImageMedia(editor, mediaPath, url) {
+ const stores = getImageMediaStores(editor);
+ if (!stores.length) {
+ editor.storage.image.media = { [mediaPath]: url };
+ return;
+ }
+ for (const media of stores) {
+ media[mediaPath] = url;
+ }
+}
+
export async function uploadAndInsertImage({ editor, view, file, size, id }) {
const imageUploadHandler =
typeof editor.options.handleImageUpload === 'function'
@@ -74,7 +103,7 @@ export async function uploadAndInsertImage({ editor, view, file, size, id }) {
const placeholderId = id;
try {
- const existingFileNames = new Set(Object.keys(editor.storage.image.media ?? {}).map((key) => key.split('/').pop()));
+ const existingFileNames = getExistingImageFileNames(editor);
const uniqueFileName = ensureUniqueFileName(file.name, existingFileNames);
const normalizedFile =
@@ -112,7 +141,7 @@ export async function uploadAndInsertImage({ editor, view, file, size, id }) {
rId,
});
- editor.storage.image.media = Object.assign(editor.storage.image.media, { [mediaPath]: url });
+ registerImageMedia(editor, mediaPath, url);
// If we are in collaboration, we need to share the image with other clients
if (editor.options.ydoc && typeof editor.commands.addImageToCollaboration === 'function') {
@@ -136,6 +165,21 @@ export async function uploadAndInsertImage({ editor, view, file, size, id }) {
}
export function addImageRelationship({ editor, path }) {
+ if (editor.options.isHeaderOrFooter) {
+ const parentEditor = editor.options.parentEditor;
+ const headerFooterRefId = editor.options.headerFooterRefId || editor.options.documentId;
+ if (!parentEditor || !headerFooterRefId) return null;
+
+ const relsPartId = resolveHeaderFooterRelsPartIdFromRefId(parentEditor, headerFooterRefId);
+ if (!relsPartId) return null;
+
+ return findOrCreateRelationship(parentEditor, 'startImageUpload:addHeaderFooterImageRelationship', {
+ target: path,
+ type: 'image',
+ partId: relsPartId,
+ });
+ }
+
return findOrCreateRelationship(editor, 'startImageUpload:addImageRelationship', {
target: path,
type: 'image',
diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.test.js
index fff31f6fb6..038b373569 100644
--- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.test.js
+++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/startImageUpload.test.js
@@ -215,6 +215,80 @@ describe('image upload helpers integration', () => {
relSpy.mockRestore();
});
+ it('registers header/footer uploads with parent media and creates a part-local relationship', async () => {
+ const id = {};
+ const parentEditor = {
+ options: {},
+ storage: { image: { media: {} } },
+ converter: {
+ documentGuid: 'doc-guid',
+ documentModified: false,
+ convertedXml: {
+ 'word/_rels/document.xml.rels': {
+ elements: [
+ {
+ name: 'Relationships',
+ elements: [
+ {
+ name: 'Relationship',
+ attributes: {
+ Id: 'rIdHeader1',
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header',
+ Target: 'header1.xml',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+ };
+ editor.options.mode = 'docx';
+ editor.options.isHeaderOrFooter = true;
+ editor.options.headerFooterRefId = 'rIdHeader1';
+ editor.options.lastSelection = editor.state.selection;
+ editor.options.parentEditor = parentEditor;
+ editor.options.handleImageUpload = vi.fn().mockResolvedValue('data:image/png;base64,HEADER');
+ editor.storage.image.media = {};
+
+ const relSpy = vi.spyOn(relsMutationModule, 'findOrCreateRelationship');
+
+ replaceSelectionWithImagePlaceholder({
+ view: editor.view,
+ editorOptions: editor.options,
+ id,
+ });
+
+ await uploadAndInsertImage({
+ editor,
+ view: editor.view,
+ file: createTestFile('header-logo.png'),
+ size: { width: 300, height: 120 },
+ id,
+ });
+
+ const imageNode = editor.state.doc.firstChild.firstChild;
+ expect(imageNode.attrs.src).toBe('word/media/header-logo.png');
+ expect(imageNode.attrs.rId).toBe('rId1');
+ expect(editor.storage.image.media['word/media/header-logo.png']).toBe('data:image/png;base64,HEADER');
+ expect(parentEditor.storage.image.media['word/media/header-logo.png']).toBe('data:image/png;base64,HEADER');
+ expect(relSpy).toHaveBeenCalledWith(parentEditor, 'startImageUpload:addHeaderFooterImageRelationship', {
+ target: 'media/header-logo.png',
+ type: 'image',
+ partId: 'word/_rels/header1.xml.rels',
+ });
+ expect(
+ parentEditor.converter.convertedXml['word/_rels/header1.xml.rels'].elements[0].elements[0].attributes,
+ ).toMatchObject({
+ Id: 'rId1',
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
+ Target: 'media/header-logo.png',
+ });
+
+ relSpy.mockRestore();
+ });
+
it('sanitizes filenames with special whitespace and avoids collisions', async () => {
const weirdName = 'Screenshot_2025-09-22 at 3.45.41\u202fPM.png';
const uploadStub = vi.fn().mockResolvedValue('data:image/png;base64,DDD');
diff --git a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js
index 030a9b26aa..29b4217b72 100644
--- a/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js
+++ b/packages/super-editor/src/editors/v1/extensions/pagination/pagination-helpers.js
@@ -208,6 +208,7 @@ export const createHeaderFooterEditor = ({
totalPageCount,
element: editorContainer,
editorOptions: {
+ headerFooterRefId,
headerFooterType: type,
onCreate: (evt) => setEditorToolbar(evt, editor),
onBlur: (evt) => onHeaderFooterDataUpdate(evt, editor, headerFooterRefId, type),