From 26591aec03dfb67a6db37ba691a0f620fc642026 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 15 May 2026 18:41:40 +0300 Subject: [PATCH] fix: upload large images/resize images in headers/footers --- .../v1/components/ImageResizeOverlay.test.js | 68 ++++++++++++++ .../v1/components/ImageResizeOverlay.vue | 30 ++++-- .../src/editors/v1/components/SuperEditor.vue | 2 +- .../src/editors/v1/core/Editor.ts | 9 +- .../editors/v1/core/Editor.webLayout.test.ts | 33 +++++++ .../editors/v1/core/helpers/word-part-path.js | 30 ++++++ .../v1/core/helpers/word-part-path.test.js | 24 +++++ .../core/parts/adapters/header-footer-sync.ts | 27 +++++- .../parts/adapters/relationships-mutation.ts | 93 +++++++++++++------ .../dom/EditorStyleInjector.test.ts | 8 ++ .../dom/EditorStyleInjector.ts | 10 ++ .../v1/core/super-converter/SuperConverter.js | 47 ++++++---- .../wp/helpers/decode-image-node-helpers.js | 30 ++++-- .../helpers/decode-image-node-helpers.test.js | 26 ++++++ .../header-footers-adapter.ts | 6 +- .../helpers/header-footer-parts.ts | 6 +- .../header-footer-slot-materialization.ts | 3 +- .../v1/extensions/diffing/part-paths.ts | 9 +- .../image/imageHelpers/startImageUpload.js | 48 +++++++++- .../imageHelpers/startImageUpload.test.js | 74 +++++++++++++++ .../pagination/pagination-helpers.js | 1 + 21 files changed, 502 insertions(+), 82 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/helpers/word-part-path.js create mode 100644 packages/super-editor/src/editors/v1/core/helpers/word-part-path.test.js 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),