Skip to content

Commit 88276ca

Browse files
authored
Merge branch 'main' into tadeu/sd-3347-feature-render-word-style-underlines
2 parents cdbbc77 + 51c5f50 commit 88276ca

19 files changed

Lines changed: 1601 additions & 67 deletions

File tree

.github/workflows/superdoc-oss-sync-to-labs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: Notify Labs of OSS changes
33
on:
44
push:
55
branches:
6-
- nick/sync
6+
- main
77

88
permissions:
99
contents: read

demos/editor/custom-ui/src/components/ActivitySidebar.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,32 @@ export function ActivitySidebar({ composeOpen, onCloseComposer, decided }: Props
7070
// (separate ticket), we'll be able to interleave by document
7171
// position; until then this stable two-bucket ordering matches what
7272
// the controller used to do internally.
73+
//
74+
// SuperDoc models tracked changes as comment-linked entities, so
75+
// `ui.comments.items` mirrors each tracked change as a synthetic
76+
// comment whose id is the tracked-change id. Without de-duping, every
77+
// suggestion shows twice (one comment card + one change card).
78+
//
79+
// We dedupe by id, NOT by the `trackedChange` flag: a real comment
80+
// thread also gets `trackedChange: true` when its anchor overlaps a
81+
// suggestion (`comments.list()` links it via `assignTrackedChangeLink`),
82+
// so a flag filter would wrongly hide those discussions. Only the
83+
// synthetic rows reuse a tracked-change id; real comments have their
84+
// own. Note: this demo runs `replacements: 'independent'`, so both
85+
// sides of a replacement surface as separate change rows and both
86+
// synthetic comment ids land in the dedupe set. Under the default
87+
// `'paired'` mode the collapsed row drops the delete-side id, which
88+
// `trackChanges.list()` does not currently expose — the delete-side
89+
// synthetic comment would leak as a duplicate. Engine follow-up needed
90+
// before this pattern is safe in paired mode.
7391
const feed = useMemo<ActivityItem[]>(() => {
92+
const changeIds = new Set<string>();
93+
for (const tc of trackChanges.items) changeIds.add(tc.id);
7494
const items: ActivityItem[] = [];
75-
for (const c of comments.items) items.push({ kind: 'comment', id: c.id, comment: c });
95+
for (const c of comments.items) {
96+
if (changeIds.has(c.id)) continue;
97+
items.push({ kind: 'comment', id: c.id, comment: c });
98+
}
7699
for (const tc of trackChanges.items) items.push({ kind: 'change', id: tc.id, change: tc.change });
77100
return items;
78101
}, [comments.items, trackChanges.items]);

packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ import {
3131
resolveParagraphProperties,
3232
resolveRunProperties,
3333
resolveDocxFontFamily,
34-
getNumberingProperties,
3534
type ParagraphFrameProperties,
3635
type ParagraphProperties,
3736
type RunProperties,
3837
} from '@superdoc/style-engine/ooxml';
3938
import { resolveSectionDirection, resolveParagraphDirection } from '../direction/index.js';
39+
import { numberingDefinesMarkerFontFamily } from '../numbering-marker-font.js';
4040

4141
const DEFAULT_DECIMAL_SEPARATOR = '.';
4242
const DEFAULT_TAB_INTERVAL_TWIPS = 720; // 0.5 inch
@@ -421,20 +421,14 @@ export const computeParagraphAttrs = (
421421
// fully defines marker font.
422422
let markerFontFallback: Partial<ParagraphFont> | undefined;
423423
if (!hasExplicitParagraphRunProperties(paragraphProperties) && previousParagraphFont) {
424-
// Detect whether numbering explicitly overrides the marker font family
425-
// (e.g. Symbol/Wingdings). If it does, we must NOT overwrite it.
426-
const numProps = paragraphProperties.numberingProperties;
427-
const numId = numProps?.numId;
428-
const ilvl = numProps?.ilvl ?? 0;
429-
const numberingRunProps =
430-
numId != null && numId !== 0
431-
? getNumberingProperties<RunProperties>('runProperties', converterContext!, ilvl, numId)
432-
: ({} as RunProperties);
433-
const numberingDefinesMarkerFontFamily = numberingRunProps.fontFamily != null;
424+
const pinsMarkerFontFamily = numberingDefinesMarkerFontFamily(
425+
paragraphProperties.numberingProperties,
426+
converterContext,
427+
);
434428

435429
markerFontFallback = {
436430
// When numbering explicitly sets a marker font (Symbol/Wingdings), keep it.
437-
fontFamily: numberingDefinesMarkerFontFamily ? undefined : previousParagraphFont.fontFamily,
431+
fontFamily: pinsMarkerFontFamily ? undefined : previousParagraphFont.fontFamily,
438432
// Preserve existing behavior: if the paragraph has no explicit run props,
439433
// marker font size inherits from the previous paragraph.
440434
fontSize: previousParagraphFont.fontSize,

packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,44 @@ describe('getLastParagraphFont', () => {
352352
const result = getLastParagraphFont(blocks);
353353
expect(result).toBeUndefined();
354354
});
355+
356+
it('skips empty first runs even when they carry stale font values', () => {
357+
const blocks: FlowBlock[] = [
358+
{
359+
kind: 'paragraph',
360+
id: '0-paragraph',
361+
runs: [
362+
{
363+
kind: 'text',
364+
text: 'Valid',
365+
fontFamily: 'ValidFont',
366+
fontSize: 11,
367+
pmStart: 0,
368+
pmEnd: 5,
369+
},
370+
],
371+
attrs: {},
372+
},
373+
{
374+
kind: 'paragraph',
375+
id: '1-paragraph',
376+
runs: [
377+
{
378+
kind: 'text',
379+
text: '',
380+
fontFamily: 'StaleFont',
381+
fontSize: 42,
382+
pmStart: 5,
383+
pmEnd: 5,
384+
},
385+
],
386+
attrs: {},
387+
},
388+
];
389+
390+
const result = getLastParagraphFont(blocks);
391+
expect(result).toEqual({ fontFamily: 'ValidFont', fontSize: 11 });
392+
});
355393
});
356394

357395
describe('paragraph converters', () => {

packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { applyTrackedChangesModeToRuns } from '../tracked-changes.js';
3737
import { textNodeToRun } from './inline-converters/text-run.js';
3838
import { DEFAULT_HYPERLINK_CONFIG, TOKEN_INLINE_TYPES } from '../constants.js';
3939
import { computeRunAttrs, hasExplicitParagraphRunProperties } from '../attributes/paragraph.js';
40+
import { syncListMarkerFontFromParagraphRuns } from '../list-marker-font.js';
4041
import { resolveRunProperties } from '@superdoc/style-engine/ooxml';
4142
import { footnoteReferenceToBlock } from './inline-converters/footnote-reference.js';
4243
import { endnoteReferenceToBlock } from './inline-converters/endnote-reference.js';
@@ -604,6 +605,19 @@ export function paragraphToFlowBlocks({
604605
const defaultSize =
605606
usePreviousFont && previousParagraphFont.fontSize ? previousParagraphFont.fontSize : extracted.defaultSize;
606607

608+
const finalizeParagraphBlocks = (outputBlocks: FlowBlock[]): FlowBlock[] => {
609+
outputBlocks.forEach((block) => {
610+
if (block.kind === 'paragraph') {
611+
syncListMarkerFontFromParagraphRuns({
612+
block,
613+
converterContext,
614+
para,
615+
});
616+
}
617+
});
618+
return outputBlocks;
619+
};
620+
607621
if (paragraphAttrs.pageBreakBefore) {
608622
blocks.push({
609623
kind: 'pageBreak',
@@ -615,7 +629,7 @@ export function paragraphToFlowBlocks({
615629

616630
if (!para.content || para.content.length === 0) {
617631
if (paragraphProps.runProperties?.vanish) {
618-
return blocks;
632+
return finalizeParagraphBlocks(blocks);
619633
}
620634
const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps, storyKey);
621635
// Get the PM position of the empty paragraph for caret rendering
@@ -650,12 +664,12 @@ export function paragraphToFlowBlocks({
650664
sourceAnchor,
651665
});
652666
if (!trackedChangesConfig) {
653-
return blocks;
667+
return finalizeParagraphBlocks(blocks);
654668
}
655669

656670
const paragraphBlock = blocks[blocks.length - 1];
657671
if (paragraphBlock?.kind !== 'paragraph') {
658-
return blocks;
672+
return finalizeParagraphBlocks(blocks);
659673
}
660674

661675
const filteredRuns = applyTrackedChangesModeToRuns(
@@ -682,7 +696,7 @@ export function paragraphToFlowBlocks({
682696

683697
if (trackedChangesConfig.enabled && (filteredRuns.length === 0 || isGhostTrackedListArtifact)) {
684698
blocks.pop();
685-
return blocks;
699+
return finalizeParagraphBlocks(blocks);
686700
}
687701

688702
paragraphBlock.runs = filteredRuns;
@@ -691,7 +705,7 @@ export function paragraphToFlowBlocks({
691705
trackedChangesMode: trackedChangesConfig.mode,
692706
trackedChangesEnabled: trackedChangesConfig.enabled,
693707
};
694-
return blocks;
708+
return finalizeParagraphBlocks(blocks);
695709
}
696710

697711
let currentRuns: Run[] = [];
@@ -923,7 +937,7 @@ export function paragraphToFlowBlocks({
923937
});
924938

925939
if (!trackedChangesConfig) {
926-
return blocks;
940+
return finalizeParagraphBlocks(blocks);
927941
}
928942

929943
const processedBlocks: FlowBlock[] = [];
@@ -953,7 +967,7 @@ export function paragraphToFlowBlocks({
953967
processedBlocks.push(block);
954968
});
955969

956-
return processedBlocks;
970+
return finalizeParagraphBlocks(processedBlocks);
957971
}
958972

959973
type InlineConverterSpec = {
@@ -1072,7 +1086,10 @@ export function getLastParagraphFont(blocks: FlowBlock[]): ParagraphFont | undef
10721086
const para = block as ParagraphBlock;
10731087
const firstRun = para.runs?.[0];
10741088
if (!firstRun) continue;
1075-
const run = firstRun as { fontFamily?: string; fontSize?: number };
1089+
const run = firstRun as { text?: string; fontFamily?: string; fontSize?: number };
1090+
if (typeof run.text === 'string' && run.text.length === 0) {
1091+
continue;
1092+
}
10761093
const fontFamily = typeof run.fontFamily === 'string' ? run.fontFamily.trim() : '';
10771094
const fontSize = typeof run.fontSize === 'number' && Number.isFinite(run.fontSize) ? run.fontSize : NaN;
10781095
if (fontFamily.length > 0 && fontSize > 0) {
@@ -1142,15 +1159,26 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext):
11421159
// get() returns both the entry (if hit) and pre-computed nodeJson to avoid double serialization
11431160
const { entry: cached, nodeJson, nodeRev } = flowBlockCache.get(prefixedStableId, node);
11441161
if (cached) {
1145-
// Cache hit: reuse blocks with position adjustment
1146-
// Cache hit reuses previously-converted blocks as-is. That means we don't
1147-
// recompute previousParagraphFont (used for empty list items without
1148-
// explicit run properties). If the user changes the font on the prior
1149-
// paragraph (e.g. paragraph A), an empty list item (paragraph B) can keep
1150-
// the old font until the cache entry is invalidated. Narrow case, but
1151-
// avoids confusing incremental-edit behavior.
1162+
// Cache hit: reuse blocks with position adjustment, then re-sync marker font
1163+
// from live PM state. Empty list items have no textStyle marks, so pass
1164+
// previousParagraphFont instead of falling back to stale cached runs.
11521165
const delta = pmStart - cached.pmStart;
11531166
const reusedBlocks = shiftCachedBlocks(cached.blocks, delta);
1167+
const paragraphProps = node.attrs?.paragraphProperties as ParagraphProperties | undefined;
1168+
const previousParagraphFont = !hasExplicitParagraphRunProperties(paragraphProps)
1169+
? getLastParagraphFont(blocks)
1170+
: undefined;
1171+
reusedBlocks.forEach((block) => {
1172+
if (block.kind === 'paragraph') {
1173+
syncListMarkerFontFromParagraphRuns({
1174+
block,
1175+
converterContext,
1176+
para: node,
1177+
contentFontSource: 'paragraph',
1178+
previousParagraphFont,
1179+
});
1180+
}
1181+
});
11541182
applyTrackedGhostListAdjustments(node, reusedBlocks, context);
11551183

11561184
reusedBlocks.forEach((block) => {

0 commit comments

Comments
 (0)