Skip to content

Commit 6afb062

Browse files
authored
fix: tracked changes in headers/footers issues (#2934)
* fix: footer tc issus, selection issues * chore: behavior tests * chore: additional fixes
1 parent 884c7fb commit 6afb062

7 files changed

Lines changed: 454 additions & 40 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1928,7 +1928,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
19281928
}
19291929
}
19301930

1931-
if (emitUpdate) {
1931+
if (emitUpdate && this.state) {
19321932
this.emit('update', { editor: this, transaction: this.state.tr });
19331933
}
19341934
}

packages/super-editor/src/editors/v1/core/extensions/editable.js

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,17 @@ import { Plugin, PluginKey } from 'prosemirror-state';
22
import { __endComposition } from 'prosemirror-view';
33
import { Extension } from '../Extension.js';
44

5-
const handleBackwardReplaceInsertText = (view, event) => {
5+
const handleInsertTextBeforeInput = (view, event) => {
66
const isInsertTextInput = event?.inputType === 'insertText';
77
const hasTextData = typeof event?.data === 'string' && event.data.length > 0;
8-
const hasNonEmptySelection = !view.state.selection.empty;
8+
const isComposing = event?.isComposing === true;
99

10-
if (!isInsertTextInput || !hasTextData || !hasNonEmptySelection) {
10+
if (!isInsertTextInput || !hasTextData || isComposing) {
1111
return false;
1212
}
1313

1414
const selection = view.state.selection;
15-
const anchor = selection.anchor ?? selection.from;
16-
const head = selection.head ?? selection.to;
17-
const isBackwardSelection = anchor > head;
18-
19-
if (!isBackwardSelection) {
15+
if (selection.empty) {
2016
return false;
2117
}
2218

@@ -104,9 +100,11 @@ export const Editable = Extension.create({
104100
__endComposition(view);
105101
}
106102

107-
// Backward (right-to-left) replacement can be misinterpreted downstream as
108-
// deleteContentBackward. Handle this narrow case explicitly at beforeinput level.
109-
if (handleBackwardReplaceInsertText(view, event)) {
103+
// When typing over an existing selection, browser-native text input
104+
// can widen the replace range around hidden inline content in story
105+
// editors. Apply the replacement against the PM selection directly
106+
// before the browser mutates the DOM.
107+
if (handleInsertTextBeforeInput(view, event)) {
110108
return true;
111109
}
112110
return false;

packages/super-editor/src/editors/v1/core/extensions/editable.test.js

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const isKeyBlocked = (editor, key, opts = {}) => {
3434
return blocked === true;
3535
};
3636

37-
describe('Editable extension backward replace handling', () => {
37+
describe('Editable extension insertText beforeinput handling', () => {
3838
let editor = null;
3939

4040
afterEach(() => {
@@ -64,6 +64,53 @@ describe('Editable extension backward replace handling', () => {
6464

6565
expect(editor.state.doc.textContent).toBe('Z');
6666
});
67+
68+
it('replaces forward non-empty selection on beforeinput insertText', () => {
69+
({ editor } = initTestEditor({
70+
mode: 'text',
71+
content: '<p>PREAMBLE</p>',
72+
}));
73+
74+
const range = findTextRange(editor.state.doc, 'PREAMBLE');
75+
expect(range).not.toBeNull();
76+
77+
const forwardSelection = TextSelection.create(editor.state.doc, range.from, range.to);
78+
editor.view.dispatch(editor.state.tr.setSelection(forwardSelection));
79+
80+
const beforeInputEvent = new InputEvent('beforeinput', {
81+
data: 'Z',
82+
inputType: 'insertText',
83+
bubbles: true,
84+
cancelable: true,
85+
});
86+
editor.view.dom.dispatchEvent(beforeInputEvent);
87+
88+
expect(editor.state.doc.textContent).toBe('Z');
89+
});
90+
91+
it('does not intercept collapsed beforeinput insertText', () => {
92+
({ editor } = initTestEditor({
93+
mode: 'text',
94+
content: '<p>QA</p>',
95+
}));
96+
97+
const range = findTextRange(editor.state.doc, 'QA');
98+
expect(range).not.toBeNull();
99+
100+
const cursor = TextSelection.create(editor.state.doc, range.to, range.to);
101+
editor.view.dispatch(editor.state.tr.setSelection(cursor));
102+
103+
const beforeInputEvent = new InputEvent('beforeinput', {
104+
data: '!',
105+
inputType: 'insertText',
106+
bubbles: true,
107+
cancelable: true,
108+
});
109+
const prevented = !editor.view.dom.dispatchEvent(beforeInputEvent);
110+
111+
expect(prevented).toBe(false);
112+
expect(editor.state.doc.textContent).toBe('QA');
113+
});
67114
});
68115

69116
describe('Editable extension – allowSelectionInViewMode', () => {

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

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2703,27 +2703,49 @@ export class PresentationEditor extends EventEmitter {
27032703
return null;
27042704
}
27052705

2706+
const noteContext = this.#buildActiveNoteLayoutContext();
2707+
if (noteContext) {
2708+
const rawHit =
2709+
this.#resolveNoteDomHit(noteContext, clientX, clientY) ??
2710+
clickToPositionGeometry(this.#layoutState.layout, noteContext.blocks, noteContext.measures, normalized, {
2711+
geometryHelper: this.#pageGeometryHelper ?? undefined,
2712+
});
2713+
if (!rawHit) {
2714+
return null;
2715+
}
2716+
2717+
const doc = this.getActiveEditor().state?.doc;
2718+
if (!doc) {
2719+
return rawHit;
2720+
}
2721+
2722+
return {
2723+
...rawHit,
2724+
pos: Math.max(0, Math.min(rawHit.pos, doc.content.size)),
2725+
};
2726+
}
2727+
27062728
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
27072729
if (sessionMode !== 'body') {
27082730
const context = this.#getHeaderFooterContext();
27092731
if (!context) {
27102732
return null;
27112733
}
2712-
const headerPageHeight = context.layout.pageSize?.h ?? context.region.height ?? 1;
2734+
const pageGap = this.#layoutState.layout?.pageGap ?? this.#getEffectivePageGap();
27132735
const bodyPageHeight = this.#getBodyPageHeight();
2714-
const pageIndex = Math.max(0, Math.floor(normalized.y / bodyPageHeight));
2736+
const pageIndex = normalized.pageIndex ?? Math.max(0, Math.floor(normalized.y / (bodyPageHeight + pageGap)));
27152737
if (pageIndex !== context.region.pageIndex) {
27162738
return null;
27172739
}
27182740
const localX = normalized.x - context.region.localX;
2719-
const localY = normalized.y - context.region.pageIndex * bodyPageHeight - context.region.localY;
2741+
const pageLocalY = normalized.pageLocalY ?? normalized.y - context.region.pageIndex * (bodyPageHeight + pageGap);
2742+
const localY = pageLocalY - context.region.localY;
27202743
if (localX < 0 || localY < 0 || localX > context.region.width || localY > context.region.height) {
27212744
return null;
27222745
}
2723-
const headerPageIndex = Math.floor(localY / headerPageHeight);
27242746
const headerPoint = {
27252747
x: localX,
2726-
y: headerPageIndex * headerPageHeight + (localY - headerPageIndex * headerPageHeight),
2748+
y: localY,
27272749
};
27282750
const hit = clickToPositionGeometry(context.layout, context.blocks, context.measures, headerPoint) ?? null;
27292751
if (!hit) {
@@ -2741,28 +2763,6 @@ export class PresentationEditor extends EventEmitter {
27412763
};
27422764
}
27432765

2744-
const noteContext = this.#buildActiveNoteLayoutContext();
2745-
if (noteContext) {
2746-
const rawHit =
2747-
this.#resolveNoteDomHit(noteContext, clientX, clientY) ??
2748-
clickToPositionGeometry(this.#layoutState.layout, noteContext.blocks, noteContext.measures, normalized, {
2749-
geometryHelper: this.#pageGeometryHelper ?? undefined,
2750-
});
2751-
if (!rawHit) {
2752-
return null;
2753-
}
2754-
2755-
const doc = this.getActiveEditor().state?.doc;
2756-
if (!doc) {
2757-
return rawHit;
2758-
}
2759-
2760-
return {
2761-
...rawHit,
2762-
pos: Math.max(0, Math.min(rawHit.pos, doc.content.size)),
2763-
};
2764-
}
2765-
27662766
if (!this.#layoutState.layout) {
27672767
return null;
27682768
}
@@ -6827,6 +6827,10 @@ export class PresentationEditor extends EventEmitter {
68276827
target: RenderedNoteTarget,
68286828
options: { clientX: number; clientY: number; pageIndex?: number },
68296829
): boolean {
6830+
if ((this.#headerFooterSession?.session?.mode ?? 'body') !== 'body') {
6831+
this.#headerFooterSession?.exitMode();
6832+
}
6833+
68306834
const storySessionManager = this.#ensureStorySessionManager();
68316835

68326836
if (target.storyType !== 'footnote' && target.storyType !== 'endnote') {

tests/behavior/helpers/story-fixtures.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,103 @@ function multiPageHeaderFooterDocumentXml(): string {
127127
`;
128128
}
129129

130+
function twoSectionFooterDocumentXml(): string {
131+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
132+
<w:document xmlns:w="${NS_W}" xmlns:r="${NS_R}">
133+
<w:body>
134+
<w:p>
135+
<w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
136+
<w:r><w:t>Section 1</w:t></w:r>
137+
</w:p>
138+
<w:p>
139+
<w:r><w:t>First section body content.</w:t></w:r>
140+
</w:p>
141+
<w:p>
142+
<w:pPr>
143+
<w:sectPr>
144+
<w:footerReference w:type="default" r:id="rId10"/>
145+
<w:type w:val="nextPage"/>
146+
<w:pgSz w:w="12240" w:h="15840"/>
147+
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
148+
<w:cols w:space="720"/>
149+
<w:docGrid w:linePitch="360"/>
150+
</w:sectPr>
151+
</w:pPr>
152+
</w:p>
153+
<w:p>
154+
<w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
155+
<w:r><w:t>Section 2</w:t></w:r>
156+
</w:p>
157+
<w:p>
158+
<w:r><w:t>Second section content lives on the next page.</w:t></w:r>
159+
</w:p>
160+
<w:sectPr>
161+
<w:footerReference w:type="default" r:id="rId9"/>
162+
<w:pgSz w:w="12240" w:h="15840"/>
163+
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
164+
<w:cols w:space="720"/>
165+
<w:docGrid w:linePitch="360"/>
166+
</w:sectPr>
167+
</w:body>
168+
</w:document>
169+
`;
170+
}
171+
172+
function footerFootnoteTransitionDocumentXml(): string {
173+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
174+
<w:document xmlns:w="${NS_W}" xmlns:r="${NS_R}">
175+
<w:body>
176+
<w:p>
177+
<w:r><w:t>Footer transition anchor</w:t></w:r>
178+
<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteReference w:id="1"/></w:r>
179+
<w:r><w:t>.</w:t></w:r>
180+
</w:p>
181+
<w:sectPr>
182+
<w:footerReference w:type="default" r:id="rId10"/>
183+
<w:pgSz w:w="12240" w:h="15840"/>
184+
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
185+
<w:cols w:space="720"/>
186+
<w:docGrid w:linePitch="360"/>
187+
</w:sectPr>
188+
</w:body>
189+
</w:document>
190+
`;
191+
}
192+
193+
function simpleFootnotesXml(): string {
194+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
195+
<w:footnotes xmlns:w="${NS_W}" xmlns:r="${NS_R}">
196+
<w:footnote w:type="separator" w:id="-1">
197+
<w:p><w:r><w:separator/></w:r></w:p>
198+
</w:footnote>
199+
<w:footnote w:type="continuationSeparator" w:id="0">
200+
<w:p><w:r><w:continuationSeparator/></w:r></w:p>
201+
</w:footnote>
202+
<w:footnote w:id="1">
203+
<w:p>
204+
<w:pPr><w:pStyle w:val="FootnoteText"/></w:pPr>
205+
<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteRef/></w:r>
206+
<w:r><w:t xml:space="preserve"> This is a simple footnote</w:t></w:r>
207+
</w:p>
208+
</w:footnote>
209+
</w:footnotes>
210+
`;
211+
}
212+
213+
function simpleFooterXml(text: string): string {
214+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
215+
<w:ftr xmlns:w="${NS_W}" xmlns:r="${NS_R}">
216+
<w:p>
217+
<w:pPr>
218+
<w:pStyle w:val="Footer"/>
219+
<w:jc w:val="center"/>
220+
</w:pPr>
221+
<w:r><w:t>${text}</w:t></w:r>
222+
</w:p>
223+
</w:ftr>
224+
`;
225+
}
226+
130227
function complexFootnotesXml(): string {
131228
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
132229
<w:footnotes xmlns:w="${NS_W}" xmlns:r="${NS_R}">
@@ -244,6 +341,23 @@ function trackedFooterXml(): string {
244341
`;
245342
}
246343

344+
function inlinePageFieldFooterXml(): string {
345+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
346+
<w:ftr xmlns:w="${NS_W}" xmlns:r="${NS_R}">
347+
<w:p>
348+
<w:pPr>
349+
<w:pStyle w:val="Footer"/>
350+
<w:jc w:val="center"/>
351+
</w:pPr>
352+
<w:r><w:t xml:space="preserve">Finance QA </w:t></w:r>
353+
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
354+
<w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
355+
<w:r><w:fldChar w:fldCharType="end"/></w:r>
356+
</w:p>
357+
</w:ftr>
358+
`;
359+
}
360+
247361
function trackedFootnotesXml(): string {
248362
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
249363
<w:footnotes xmlns:w="${NS_W}" xmlns:r="${NS_R}">
@@ -313,6 +427,27 @@ export const MULTI_PAGE_HEADER_FOOTER_DOC_PATH = ensureGeneratedFixture(
313427
'word/document.xml': multiPageHeaderFooterDocumentXml(),
314428
},
315429
);
430+
export const TWO_SECTION_FOOTER_DOC_PATH = ensureGeneratedFixture('two-section-footer.docx', 'h_f-normal.docx', {
431+
'word/document.xml': twoSectionFooterDocumentXml(),
432+
'word/footer1.xml': simpleFooterXml('Appendix footer'),
433+
'word/footer2.xml': simpleFooterXml('Main footer'),
434+
});
435+
export const FOOTER_FOOTNOTE_TRANSITION_DOC_PATH = ensureGeneratedFixture(
436+
'footer-footnote-transition.docx',
437+
'h_f-normal.docx',
438+
{
439+
'word/document.xml': footerFootnoteTransitionDocumentXml(),
440+
'word/footnotes.xml': simpleFootnotesXml(),
441+
'word/footer2.xml': simpleFooterXml('Transition footer'),
442+
},
443+
);
444+
export const FOOTER_INLINE_PAGE_FIELD_DOC_PATH = ensureGeneratedFixture(
445+
'footer-inline-page-field.docx',
446+
'h_f-normal.docx',
447+
{
448+
'word/footer2.xml': inlinePageFieldFooterXml(),
449+
},
450+
);
316451
export const STORY_ONLY_TRACKED_CHANGES_DOC_PATH = ensureGeneratedFixture(
317452
'story-only-tracked-changes.docx',
318453
'h_f-normal.docx',

0 commit comments

Comments
 (0)