Skip to content

Commit d6d1980

Browse files
committed
fix: story based endnotes, ttests
1 parent d676a68 commit d6d1980

34 files changed

Lines changed: 1902 additions & 142 deletions

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

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,10 @@ import { HeaderFooterSessionManager } from './header-footer/HeaderFooterSessionM
8080
import { StoryPresentationSessionManager } from './story-session/StoryPresentationSessionManager.js';
8181
import type { StoryPresentationSession } from './story-session/types.js';
8282
import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js';
83-
import { BODY_STORY_KEY, buildStoryKey } from '../../document-api-adapters/story-runtime/story-key.js';
83+
import { BODY_STORY_KEY, buildStoryKey, parseStoryKey } from '../../document-api-adapters/story-runtime/story-key.js';
8484
import { createStoryEditor } from '../story-editor-factory.js';
8585
import { createHeaderFooterEditor } from '../../extensions/pagination/pagination-helpers.js';
86+
import { buildEndnoteBlocks } from './layout/EndnotesBuilder.js';
8687
import { toFlowBlocks, ConverterContext, FlowBlockCache } from '@superdoc/pm-adapter';
8788
import { readSettingsRoot, readDefaultTableStyle } from '../../document-api-adapters/document-settings.js';
8889
import {
@@ -184,6 +185,11 @@ function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null {
184185
return noteId ? { storyType: 'footnote', noteId } : null;
185186
}
186187

188+
if (blockId.startsWith('endnote-')) {
189+
const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? '';
190+
return noteId ? { storyType: 'endnote', noteId } : null;
191+
}
192+
187193
return null;
188194
}
189195
import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js';
@@ -4976,7 +4982,16 @@ export class PresentationEditor extends EventEmitter {
49764982
const semanticFootnoteBlocks = isSemanticFlow
49774983
? buildSemanticFootnoteBlocks(footnotesLayoutInput, this.#layoutOptions.semanticOptions?.footnotesMode)
49784984
: [];
4979-
const blocksForLayout = semanticFootnoteBlocks.length > 0 ? [...blocks, ...semanticFootnoteBlocks] : blocks;
4985+
const endnoteBlocks = buildEndnoteBlocks(
4986+
this.#editor?.state,
4987+
(this.#editor as EditorWithConverter)?.converter,
4988+
converterContext,
4989+
this.#editor?.converter?.themeColors ?? undefined,
4990+
);
4991+
const blocksForLayout =
4992+
semanticFootnoteBlocks.length > 0 || endnoteBlocks.length > 0
4993+
? [...blocks, ...semanticFootnoteBlocks, ...endnoteBlocks]
4994+
: blocks;
49804995
const layoutOptions =
49814996
!isSemanticFlow && footnotesLayoutInput
49824997
? { ...baseLayoutOptions, footnotes: footnotesLayoutInput }
@@ -7006,6 +7021,12 @@ export class PresentationEditor extends EventEmitter {
70067021
return true;
70077022
}
70087023

7024+
if (await this.#activateTrackedChangeStorySurface(entityId, storyKey)) {
7025+
if (this.#navigateToActiveStoryTrackedChange(entityId, storyKey)) {
7026+
return true;
7027+
}
7028+
}
7029+
70097030
return this.#scrollToRenderedTrackedChange(entityId, storyKey);
70107031
}
70117032

@@ -7043,6 +7064,77 @@ export class PresentationEditor extends EventEmitter {
70437064
return true;
70447065
}
70457066

7067+
async #activateTrackedChangeStorySurface(entityId: string, storyKey: string): Promise<boolean> {
7068+
let locator: StoryLocator | null = null;
7069+
try {
7070+
locator = parseStoryKey(storyKey);
7071+
} catch {
7072+
return false;
7073+
}
7074+
7075+
if (!locator || locator.storyType === 'body') {
7076+
return false;
7077+
}
7078+
7079+
const candidate = this.#findRenderedTrackedChangeElements(entityId, storyKey)[0] ?? null;
7080+
if (!candidate) {
7081+
return false;
7082+
}
7083+
7084+
const rect = candidate.getBoundingClientRect();
7085+
const clientX = rect.left + Math.max(rect.width / 2, 1);
7086+
const clientY = rect.top + Math.max(rect.height / 2, 1);
7087+
const pageIndex = this.#resolveRenderedPageIndexForElement(candidate);
7088+
7089+
if (locator.storyType === 'footnote' || locator.storyType === 'endnote') {
7090+
try {
7091+
if (
7092+
!this.#activateRenderedNoteSession(
7093+
{
7094+
storyType: locator.storyType,
7095+
noteId: locator.noteId,
7096+
},
7097+
{ clientX, clientY, pageIndex },
7098+
)
7099+
) {
7100+
return false;
7101+
}
7102+
} catch {
7103+
return false;
7104+
}
7105+
7106+
return this.#waitForTrackedChangeStorySurface(storyKey);
7107+
}
7108+
7109+
if (locator.storyType !== 'headerFooterPart') {
7110+
return false;
7111+
}
7112+
7113+
const pageElement = candidate.closest<HTMLElement>('.superdoc-page');
7114+
const pageRect = pageElement?.getBoundingClientRect();
7115+
const pageLocalY = pageRect ? clientY - pageRect.top : undefined;
7116+
const region = this.#hitTestHeaderFooterRegion(clientX, clientY, pageIndex, pageLocalY);
7117+
if (!region) {
7118+
return false;
7119+
}
7120+
7121+
this.#activateHeaderFooterRegion(region);
7122+
return this.#waitForTrackedChangeStorySurface(storyKey);
7123+
}
7124+
7125+
async #waitForTrackedChangeStorySurface(storyKey: string, timeoutMs = 500): Promise<boolean> {
7126+
const deadline = Date.now() + timeoutMs;
7127+
7128+
while (Date.now() < deadline) {
7129+
if (this.#getActiveTrackedChangeStorySurface()?.storyKey === storyKey) {
7130+
return true;
7131+
}
7132+
await new Promise((resolve) => setTimeout(resolve, 16));
7133+
}
7134+
7135+
return this.#getActiveTrackedChangeStorySurface()?.storyKey === storyKey;
7136+
}
7137+
70467138
#navigateToActiveStoryTrackedChange(entityId: string, storyKey: string): boolean {
70477139
const activeSurface = this.#getActiveTrackedChangeStorySurface();
70487140
if (!activeSurface || activeSurface.storyKey !== storyKey) {
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { EditorState } from 'prosemirror-state';
2+
import type { FlowBlock } from '@superdoc/contracts';
3+
import { toFlowBlocks, type ConverterContext } from '@superdoc/pm-adapter';
4+
import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js';
5+
6+
import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js';
7+
import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js';
8+
import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js';
9+
10+
export type EndnoteConverterLike = {
11+
endnotes?: Array<{ id?: unknown; content?: unknown[] }>;
12+
};
13+
14+
type Run = {
15+
kind?: string;
16+
text?: string;
17+
fontFamily?: string;
18+
fontSize?: number;
19+
bold?: boolean;
20+
italic?: boolean;
21+
letterSpacing?: number;
22+
color?: unknown;
23+
vertAlign?: 'superscript' | 'subscript' | 'baseline';
24+
baselineShift?: number;
25+
pmStart?: number | null;
26+
pmEnd?: number | null;
27+
dataAttrs?: Record<string, string>;
28+
};
29+
30+
type ParagraphBlock = FlowBlock & {
31+
kind: 'paragraph';
32+
runs?: Run[];
33+
};
34+
35+
const ENDNOTE_MARKER_DATA_ATTR = 'data-sd-endnote-number';
36+
const DEFAULT_MARKER_FONT_FAMILY = 'Arial';
37+
const DEFAULT_MARKER_FONT_SIZE = 12;
38+
39+
export function buildEndnoteBlocks(
40+
editorState: EditorState | null | undefined,
41+
converter: EndnoteConverterLike | null | undefined,
42+
converterContext: ConverterContext | undefined,
43+
themeColors: unknown,
44+
): FlowBlock[] {
45+
if (!editorState) return [];
46+
47+
const endnoteNumberById = converterContext?.endnoteNumberById;
48+
const importedEndnotes = Array.isArray(converter?.endnotes) ? converter.endnotes : [];
49+
if (importedEndnotes.length === 0) return [];
50+
51+
const orderedEndnoteIds: string[] = [];
52+
const seen = new Set<string>();
53+
54+
editorState.doc.descendants((node) => {
55+
if (node.type?.name !== 'endnoteReference') return;
56+
const id = node.attrs?.id;
57+
if (id == null) return;
58+
const key = String(id);
59+
if (!key || seen.has(key)) return;
60+
seen.add(key);
61+
orderedEndnoteIds.push(key);
62+
});
63+
64+
if (orderedEndnoteIds.length === 0) return [];
65+
66+
const blocks: FlowBlock[] = [];
67+
68+
orderedEndnoteIds.forEach((id) => {
69+
const entry = findNoteEntryById(importedEndnotes, id);
70+
const content = entry?.content;
71+
if (!Array.isArray(content) || content.length === 0) return;
72+
73+
try {
74+
const clonedContent = JSON.parse(JSON.stringify(content));
75+
const endnoteDoc = normalizeNotePmJson({ type: 'doc', content: clonedContent });
76+
const result = toFlowBlocks(endnoteDoc, {
77+
blockIdPrefix: `endnote-${id}-`,
78+
storyKey: buildStoryKey({ kind: 'story', storyType: 'endnote', noteId: id }),
79+
enableRichHyperlinks: true,
80+
themeColors: themeColors as never,
81+
converterContext: converterContext as never,
82+
});
83+
84+
if (result?.blocks?.length) {
85+
ensureEndnoteMarker(result.blocks, id, endnoteNumberById);
86+
blocks.push(...result.blocks);
87+
}
88+
} catch {}
89+
});
90+
91+
return blocks;
92+
}
93+
94+
function isEndnoteMarker(run: Run): boolean {
95+
return Boolean(run.dataAttrs?.[ENDNOTE_MARKER_DATA_ATTR]);
96+
}
97+
98+
function resolveDisplayNumber(id: string, endnoteNumberById: Record<string, number> | undefined): number {
99+
if (!endnoteNumberById || typeof endnoteNumberById !== 'object') return 1;
100+
const num = endnoteNumberById[id];
101+
if (typeof num === 'number' && Number.isFinite(num) && num > 0) return num;
102+
return 1;
103+
}
104+
105+
function resolveMarkerFontFamily(firstTextRun: Run | undefined): string {
106+
return typeof firstTextRun?.fontFamily === 'string' ? firstTextRun.fontFamily : DEFAULT_MARKER_FONT_FAMILY;
107+
}
108+
109+
function resolveMarkerBaseFontSize(firstTextRun: Run | undefined): number {
110+
if (
111+
typeof firstTextRun?.fontSize === 'number' &&
112+
Number.isFinite(firstTextRun.fontSize) &&
113+
firstTextRun.fontSize > 0
114+
) {
115+
return firstTextRun.fontSize;
116+
}
117+
118+
return DEFAULT_MARKER_FONT_SIZE;
119+
}
120+
121+
function buildMarkerRun(markerText: string, firstTextRun: Run | undefined): Run {
122+
const markerRun: Run = {
123+
kind: 'text',
124+
text: markerText,
125+
dataAttrs: { [ENDNOTE_MARKER_DATA_ATTR]: 'true' },
126+
fontFamily: resolveMarkerFontFamily(firstTextRun),
127+
fontSize: resolveMarkerBaseFontSize(firstTextRun) * SUBSCRIPT_SUPERSCRIPT_SCALE,
128+
vertAlign: 'superscript',
129+
};
130+
131+
if (typeof firstTextRun?.bold === 'boolean') markerRun.bold = firstTextRun.bold;
132+
if (typeof firstTextRun?.italic === 'boolean') markerRun.italic = firstTextRun.italic;
133+
if (typeof firstTextRun?.letterSpacing === 'number' && Number.isFinite(firstTextRun.letterSpacing)) {
134+
markerRun.letterSpacing = firstTextRun.letterSpacing;
135+
}
136+
if (firstTextRun?.color != null) markerRun.color = firstTextRun.color;
137+
138+
return markerRun;
139+
}
140+
141+
function syncMarkerRun(target: Run, source: Run): void {
142+
target.kind = source.kind;
143+
target.text = source.text;
144+
target.dataAttrs = source.dataAttrs;
145+
target.fontFamily = source.fontFamily;
146+
target.fontSize = source.fontSize;
147+
target.bold = source.bold;
148+
target.italic = source.italic;
149+
target.letterSpacing = source.letterSpacing;
150+
target.color = source.color;
151+
target.vertAlign = source.vertAlign;
152+
target.baselineShift = source.baselineShift;
153+
delete target.pmStart;
154+
delete target.pmEnd;
155+
}
156+
157+
function ensureEndnoteMarker(
158+
blocks: FlowBlock[],
159+
id: string,
160+
endnoteNumberById: Record<string, number> | undefined,
161+
): void {
162+
const firstParagraph = blocks.find((block): block is ParagraphBlock => block.kind === 'paragraph');
163+
if (!firstParagraph) return;
164+
165+
const runs = Array.isArray(firstParagraph.runs) ? firstParagraph.runs : [];
166+
firstParagraph.runs = runs;
167+
168+
const firstTextRun = runs.find((run) => !isEndnoteMarker(run) && typeof run.text === 'string' && run.text.length > 0);
169+
const markerRun = buildMarkerRun(String(resolveDisplayNumber(id, endnoteNumberById)), firstTextRun);
170+
171+
if (runs[0] && isEndnoteMarker(runs[0])) {
172+
syncMarkerRun(runs[0], markerRun);
173+
return;
174+
}
175+
176+
runs.unshift(markerRun);
177+
}

packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,15 @@ type CommentThreadHit = {
7373
};
7474

7575
/**
76-
* Block IDs for footnote content use prefix "footnote-{id}-" (see FootnotesBuilder).
76+
* Block IDs for note content use `footnote-{id}-` / `endnote-{id}-` prefixes.
7777
* Semantic footnote blocks use the {@link isSemanticFootnoteBlockId} helper from
7878
* shared constants — it matches both heading and body footnote block IDs.
7979
*/
80-
function isFootnoteBlockId(blockId: string): boolean {
81-
return typeof blockId === 'string' && (blockId.startsWith('footnote-') || isSemanticFootnoteBlockId(blockId));
80+
function isRenderedNoteBlockId(blockId: string): boolean {
81+
return (
82+
typeof blockId === 'string' &&
83+
(blockId.startsWith('footnote-') || blockId.startsWith('endnote-') || isSemanticFootnoteBlockId(blockId))
84+
);
8285
}
8386

8487
type RenderedNoteTarget = {
@@ -101,6 +104,11 @@ function parseRenderedNoteTarget(blockId: string): RenderedNoteTarget | null {
101104
return noteId ? { storyType: 'footnote', noteId } : null;
102105
}
103106

107+
if (blockId.startsWith('endnote-')) {
108+
const noteId = blockId.slice('endnote-'.length).split('-')[0] ?? '';
109+
return noteId ? { storyType: 'endnote', noteId } : null;
110+
}
111+
104112
return null;
105113
}
106114

@@ -1379,7 +1387,7 @@ export class EditorInputManager {
13791387
}
13801388

13811389
// Disallow entering read-only note content unless it has been activated into a story session.
1382-
if (isFootnoteBlockId(rawHit.blockId) && !isNoteEditing) {
1390+
if (isRenderedNoteBlockId(rawHit.blockId) && !isNoteEditing) {
13831391
this.#focusEditor();
13841392
return;
13851393
}
@@ -2211,7 +2219,7 @@ export class EditorInputManager {
22112219
if (!rawHit || !hit) return;
22122220

22132221
// Don't extend a body selection into read-only footnote content.
2214-
if (!useActiveSurfaceHitTest && isFootnoteBlockId(rawHit.blockId)) return;
2222+
if (!useActiveSurfaceHitTest && isRenderedNoteBlockId(rawHit.blockId)) return;
22152223

22162224
const doc = editor.state?.doc;
22172225
if (!doc) return;

packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.footnoteClick.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,33 @@ describe('EditorInputManager - Footnote click selection behavior', () => {
197197
expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
198198
});
199199

200+
it('activates a note session on direct endnote fragment click', () => {
201+
const fragmentEl = document.createElement('span');
202+
fragmentEl.setAttribute('data-block-id', 'endnote-1-0');
203+
const nestedEl = document.createElement('span');
204+
fragmentEl.appendChild(nestedEl);
205+
viewportHost.appendChild(fragmentEl);
206+
207+
const PointerEventImpl = getPointerEventImpl();
208+
nestedEl.dispatchEvent(
209+
new PointerEventImpl('pointerdown', {
210+
bubbles: true,
211+
cancelable: true,
212+
button: 0,
213+
buttons: 1,
214+
clientX: 16,
215+
clientY: 12,
216+
} as PointerEventInit),
217+
);
218+
219+
expect(activateRenderedNoteSession).toHaveBeenCalledWith(
220+
{ storyType: 'endnote', noteId: '1' },
221+
expect.objectContaining({ clientX: 16, clientY: 12 }),
222+
);
223+
expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled();
224+
expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
225+
});
226+
200227
it('activates the note session and syncs the tracked-change bubble on footnote clicks', () => {
201228
const fragmentEl = document.createElement('span');
202229
fragmentEl.setAttribute('data-block-id', 'footnote-1-0');

0 commit comments

Comments
 (0)