Skip to content

Commit 429f96e

Browse files
chittolinagchittolinacaio-pizzolclaude
authored
SD-2527 - feat: implement new types of ordered lists (#2873)
* feat: implement 2 extra types of bullet lists (square, circle) * refactor: code reuse * refactor: simplified code removing font override * chore: small code tweaks * feat: extra ordered list types * chore: small UI tweaks * chore: removed console log * test(lists): add unit, behavior, and visual tests for new list styles (SD-2527) Coverage for the 10 styles this PR introduces (3 bullet + 7 ordered): - Unit: full mapping matrix for markerTextToBulletStyle and numberingInfoToOrderedStyle, plus the BULLET_STYLE_CHARS and ORDERED_LIST_STYLES override branches in numbering-transforms. - Unit: style param threading through toggleList, including the same-style-toggle-off and different-style-create cases. - Unit: headless toolbar deriver values for all 10 styles plus documented null cases (upper-alpha-paren, decimalZero, prefixed). - Unit: registry rename compatibility (legacy toggleBulletList / toggleOrderedList no longer flows through bullet-list/numbered-list). - Behavior: each toggle command produces correct OOXML and locks in current partial-selection style-switch fragmentation behavior. - Visual: fixture spec for sd-2527-list-styles.docx (auto-discovered). * fix: apply style change to entire list's level * refactor: simplify logic * feat: added upper alpha paren list style * refactor: simplify code * fix: highlight list buttons in toolbar based on cursor position * feat: apply inline list style when selection is not empty * fix: cmd + Z not undoing list style changes * fix: list style change not applying for nested children inline * fix: backward compat for toggleOrderedList * refactor: create generic list styles button * fix: tests * fix: lists alignment * fix: list alignment * fix: change repeating pattern for upper/lower alpha * fix: changing list styles affecting alignment * fix: added missing code * fix: refresh abstract id and dispatch tr * test(lists): repro tests for clone-path findings (SD-2527 review) Two passing tests that document the current behavior of cloneListDefinitionWithLevelStyle when the source num carries w:lvlOverride or when the abstract is a w:numStyleLink wrapper. Comments explain why each assertion is a bug; invert when fixing. * Revert "test(lists): repro tests for clone-path findings (SD-2527 review)" This reverts commit d537dcc. * fix: changing list level overrides styles to null * test: added tests to ensure nested lists use the correct marker * test: behavior tests to ensure markers are correct on nested lists --------- Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com> Co-authored-by: Caio Pizzol <caio@superdoc.dev> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c64a05d commit 429f96e

41 files changed

Lines changed: 2584 additions & 153 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/layout-engine/layout-bridge/src/diff.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,20 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock
397397
const bEnabled = resolveTrackedChangesEnabled(b.attrs, true);
398398
if (aEnabled !== bEnabled) return false;
399399

400+
// Marker text/justification change requires a re-measure even when the
401+
// paragraph runs are unchanged (e.g. switching list style from decimal to
402+
// alpha changes "10." → "j." — different glyph widths and thus a different
403+
// suffix-tab width). `wordLayout` is computed output but its
404+
// marker.markerText is the canvas measurement input, so we treat a change
405+
// as a dirty signal rather than relying on `paragraphAttrsEqual`'s
406+
// wordLayout exclusion.
407+
const aMarker = (a.attrs as { wordLayout?: { marker?: { markerText?: string; justification?: string } } } | undefined)
408+
?.wordLayout?.marker;
409+
const bMarker = (b.attrs as { wordLayout?: { marker?: { markerText?: string; justification?: string } } } | undefined)
410+
?.wordLayout?.marker;
411+
if ((aMarker?.markerText ?? null) !== (bMarker?.markerText ?? null)) return false;
412+
if ((aMarker?.justification ?? null) !== (bMarker?.justification ?? null)) return false;
413+
400414
// Check paragraph-level visual attributes (alignment, spacing, indent, borders, etc.)
401415
if (!paragraphAttrsEqual(a.attrs, b.attrs)) return false;
402416

packages/super-editor/src/editors/v1/components/toolbar/BulletStyleButtons.vue renamed to packages/super-editor/src/editors/v1/components/toolbar/StyleButtonsList.vue

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
11
<script setup>
2-
import { onMounted, ref } from 'vue';
2+
import { computed, onMounted, ref } from 'vue';
33
import { useHighContrastMode } from '../../composables/use-high-contrast-mode';
4-
import { toolbarIcons } from './toolbarIcons.js';
54
65
const { isHighContrastMode } = useHighContrastMode();
76
const emit = defineEmits(['select']);
87
98
const props = defineProps({
9+
buttons: {
10+
type: Array,
11+
required: true,
12+
},
1013
selectedStyle: {
1114
type: String,
1215
default: null,
1316
},
17+
iconSize: {
18+
type: Number,
19+
default: 25,
20+
},
1421
});
1522
1623
const buttonRefs = ref([]);
17-
const bulletButtons = [
18-
{ key: 'disc', icon: toolbarIcons.bulletListDisc, ariaLabel: 'Opaque circle' },
19-
{ key: 'circle', icon: toolbarIcons.bulletListCircle, ariaLabel: 'Outline circle' },
20-
{ key: 'square', icon: toolbarIcons.bulletListSquare, ariaLabel: 'Opaque square' },
21-
];
24+
25+
const iconStyle = computed(() => ({
26+
width: `${props.iconSize}px`,
27+
height: `${props.iconSize}px`,
28+
}));
2229
2330
const select = (key) => {
2431
emit('select', key);
@@ -51,7 +58,7 @@ const handleKeyDown = (e, index) => {
5158
moveToNextButton(index);
5259
break;
5360
case 'Enter':
54-
select(bulletButtons[index].key);
61+
select(props.buttons[index].key);
5562
break;
5663
default:
5764
break;
@@ -68,12 +75,13 @@ onMounted(() => {
6875
</script>
6976

7077
<template>
71-
<div class="bullet-style-buttons" :class="{ 'high-contrast': isHighContrastMode }">
78+
<div class="style-buttons-list" :class="{ 'high-contrast': isHighContrastMode }">
7279
<div
73-
v-for="(button, index) in bulletButtons"
80+
v-for="(button, index) in props.buttons"
7481
:key="button.key"
7582
class="button-icon"
7683
:class="{ selected: props.selectedStyle === button.key }"
84+
:style="iconStyle"
7785
@click="select(button.key)"
7886
v-html="button.icon"
7987
role="menuitem"
@@ -85,7 +93,7 @@ onMounted(() => {
8593
</template>
8694

8795
<style scoped>
88-
.bullet-style-buttons {
96+
.style-buttons-list {
8997
display: flex;
9098
justify-content: space-between;
9199
width: 100%;
@@ -97,8 +105,6 @@ onMounted(() => {
97105
padding: 5px;
98106
font-size: var(--sd-ui-font-size-600, 16px);
99107
color: var(--sd-ui-dropdown-text, #47484a);
100-
width: 25px;
101-
height: 25px;
102108
border-radius: var(--sd-ui-dropdown-option-radius, 3px);
103109
display: flex;
104110
justify-content: center;

packages/super-editor/src/editors/v1/components/toolbar/ToolbarButton.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const {
4646
hasCaret,
4747
splitButton,
4848
disabled,
49+
expand,
4950
inlineTextInputVisible,
5051
hasInlineTextInput,
5152
minWidth,
@@ -108,7 +109,7 @@ const onFontSizeInput = (event) => {
108109
};
109110
110111
const caretIcon = computed(() => {
111-
return active.value ? toolbarIcons.dropdownCaretUp : toolbarIcons.dropdownCaretDown;
112+
return expand?.value ? toolbarIcons.dropdownCaretUp : toolbarIcons.dropdownCaretDown;
112113
});
113114
</script>
114115

packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { normalizeFontOption } from './helpers/font-options.js';
55
import { useToolbarItem } from './use-toolbar-item';
66
import AIWriter from './AIWriter.vue';
77
import AlignmentButtons from './AlignmentButtons.vue';
8-
import BulletStyleButtons from './BulletStyleButtons.vue';
8+
import StyleButtonsList from './StyleButtonsList.vue';
9+
import { bulletStyleButtons, numberedStyleButtons } from './list-style-buttons.js';
910
import DocumentMode from './DocumentMode.vue';
1011
import LinkedStyle from './LinkedStyle.vue';
1112
import LinkInput from './LinkInput.vue';
@@ -641,7 +642,6 @@ export const makeDefaultItems = ({
641642
hasCaret: true,
642643
tooltip: toolbarTexts.bulletList,
643644
restoreEditorFocus: true,
644-
suppressActiveHighlight: true,
645645
attributes: {
646646
ariaLabel: 'Bullet list',
647647
},
@@ -655,7 +655,9 @@ export const makeDefaultItems = ({
655655
const item = { ...bulletedList, command: 'toggleBulletListStyle' };
656656
superToolbar.emitCommand({ item, argument: style });
657657
};
658-
return h(BulletStyleButtons, {
658+
return h(StyleButtonsList, {
659+
buttons: bulletStyleButtons,
660+
iconSize: 25,
659661
selectedStyle: bulletedList.selectedValue.value,
660662
onSelect: handleSelect,
661663
});
@@ -666,16 +668,37 @@ export const makeDefaultItems = ({
666668

667669
// number list
668670
const numberedList = useToolbarItem({
669-
type: 'button',
671+
type: 'dropdown',
670672
name: 'numberedlist',
671-
command: 'toggleOrderedList',
673+
command: 'toggleOrderedListStyle',
674+
splitButton: true,
675+
splitButtonCommand: 'toggleOrderedList',
672676
icon: toolbarIcons.numberedList,
673-
active: false,
677+
hasCaret: true,
674678
tooltip: toolbarTexts.numberedList,
675679
restoreEditorFocus: true,
676680
attributes: {
677681
ariaLabel: 'Numbered list',
678682
},
683+
options: [
684+
{
685+
type: 'render',
686+
key: 'numbered-style-buttons',
687+
render: () => {
688+
const handleSelect = (style) => {
689+
closeDropdown(numberedList);
690+
const item = { ...numberedList, command: 'toggleOrderedListStyle' };
691+
superToolbar.emitCommand({ item, argument: style });
692+
};
693+
return h(StyleButtonsList, {
694+
buttons: numberedStyleButtons,
695+
iconSize: 30,
696+
selectedStyle: numberedList.selectedValue.value,
697+
onSelect: handleSelect,
698+
});
699+
},
700+
},
701+
],
679702
});
680703

681704
// indent left
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { toolbarIcons } from './toolbarIcons.js';
2+
3+
export const bulletStyleButtons = [
4+
{ key: 'disc', icon: toolbarIcons.bulletListDisc, ariaLabel: 'Opaque circle' },
5+
{ key: 'circle', icon: toolbarIcons.bulletListCircle, ariaLabel: 'Outline circle' },
6+
{ key: 'square', icon: toolbarIcons.bulletListSquare, ariaLabel: 'Opaque square' },
7+
];
8+
9+
export const numberedStyleButtons = [
10+
{ key: 'decimal', icon: toolbarIcons.numberedListDecimal, ariaLabel: '1. 2. 3.' },
11+
{ key: 'decimal-paren', icon: toolbarIcons.numberedListDecimalParen, ariaLabel: '1) 2) 3)' },
12+
{ key: 'upper-roman', icon: toolbarIcons.numberedListUpperRoman, ariaLabel: 'I. II. III.' },
13+
{ key: 'lower-roman', icon: toolbarIcons.numberedListLowerRoman, ariaLabel: 'i. ii. iii.' },
14+
{ key: 'upper-alpha', icon: toolbarIcons.numberedListUpperAlpha, ariaLabel: 'A. B. C.' },
15+
{ key: 'upper-alpha-paren', icon: toolbarIcons.numberedListUpperAlphaParen, ariaLabel: 'A) B) C)' },
16+
{ key: 'lower-alpha', icon: toolbarIcons.numberedListLowerAlpha, ariaLabel: 'a. b. c.' },
17+
{ key: 'lower-alpha-paren', icon: toolbarIcons.numberedListLowerAlphaParen, ariaLabel: 'a) b) c)' },
18+
];

packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,15 @@ export class SuperToolbar extends EventEmitter {
641641
item.selectedValue.value = null;
642642
}
643643
},
644+
numberedlist: () => {
645+
if (commandState?.active) {
646+
item.activate();
647+
item.selectedValue.value = commandState.value;
648+
} else {
649+
item.deactivate();
650+
item.selectedValue.value = null;
651+
}
652+
},
644653
default: () => {
645654
if (commandState?.active) item.activate();
646655
else item.deactivate();

packages/super-editor/src/editors/v1/components/toolbar/toolbarIcons.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import listIconSvg from '@superdoc/common/icons/list-solid.svg?raw';
55
import listCircleIconSvg from '@superdoc/common/icons/list-circle-solid.svg?raw';
66
import listSquareIconSvg from '@superdoc/common/icons/list-square-solid.svg?raw';
77
import listOlIconSvg from '@superdoc/common/icons/list-ol-solid.svg?raw';
8+
import listDecimalIconSvg from '@superdoc/common/icons/list-decimal-solid.svg?raw';
9+
import listDecimalParenIconSvg from '@superdoc/common/icons/list-decimal-paren-solid.svg?raw';
10+
import listUpperRomanIconSvg from '@superdoc/common/icons/list-upper-roman-solid.svg?raw';
11+
import listLowerRomanIconSvg from '@superdoc/common/icons/list-lower-roman-solid.svg?raw';
12+
import listUpperAlphaIconSvg from '@superdoc/common/icons/list-upper-alpha-solid.svg?raw';
13+
import listUpperAlphaParenIconSvg from '@superdoc/common/icons/list-upper-alpha-paren-solid.svg?raw';
14+
import listLowerAlphaIconSvg from '@superdoc/common/icons/list-lower-alpha-solid.svg?raw';
15+
import listLowerAlphaParenIconSvg from '@superdoc/common/icons/list-lower-alpha-paren-solid.svg?raw';
816
import imageIconSvg from '@superdoc/common/icons/image-solid.svg?raw';
917
import linkIconSvg from '@superdoc/common/icons/link-solid.svg?raw';
1018
import alignLeftIconSvg from '@superdoc/common/icons/align-left-solid.svg?raw';
@@ -71,6 +79,14 @@ export const toolbarIcons = {
7179
bulletListCircle: listCircleIconSvg,
7280
bulletListSquare: listSquareIconSvg,
7381
numberedList: listOlIconSvg,
82+
numberedListDecimal: listDecimalIconSvg,
83+
numberedListDecimalParen: listDecimalParenIconSvg,
84+
numberedListUpperRoman: listUpperRomanIconSvg,
85+
numberedListLowerRoman: listLowerRomanIconSvg,
86+
numberedListUpperAlpha: listUpperAlphaIconSvg,
87+
numberedListUpperAlphaParen: listUpperAlphaParenIconSvg,
88+
numberedListLowerAlpha: listLowerAlphaIconSvg,
89+
numberedListLowerAlphaParen: listLowerAlphaParenIconSvg,
7490
indentLeft: outdentIconSvg,
7591
indentRight: indentIconSvg,
7692
pageBreak: fileHalfDashedIconSvg,

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2703,6 +2703,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
27032703
const prevState = this.state;
27042704
let nextState: EditorState;
27052705
let transactionToApply = transaction;
2706+
let effectiveTransaction: Transaction = transaction;
27062707
const forceTrackChanges = transactionToApply.getMeta('forceTrackChanges') === true;
27072708
try {
27082709
const trackChangesState = TrackChangesBasePluginKey.getState(prevState);
@@ -2723,8 +2724,12 @@ export class Editor extends EventEmitter<EditorEventMap> {
27232724
})
27242725
: transactionToApply;
27252726

2726-
const { state: appliedState } = prevState.applyTransaction(transactionToApply);
2727+
const { state: appliedState, transactions: appliedTransactions } = prevState.applyTransaction(transactionToApply);
27272728
nextState = appliedState;
2729+
// Pick whichever applied tr carries the doc delta — when the input tr is empty an
2730+
// appendTransaction plugin (e.g. numberingPlugin) may have produced the real change,
2731+
// and downstream listeners read `transaction.docChanged`/`mapping` off this tr.
2732+
effectiveTransaction = appliedTransactions.find((t) => t.docChanged) ?? transactionToApply;
27282733
} catch (error) {
27292734
if (forceTrackChanges) throw error;
27302735
// just in case
@@ -2772,7 +2777,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
27722777
});
27732778
}
27742779

2775-
if (transactionToApply.docChanged) {
2780+
if (effectiveTransaction.docChanged) {
27762781
// Track document modifications and promote to GUID if needed
27772782
if (transaction.docChanged && this.converter) {
27782783
if (!this.converter.documentGuid) {
@@ -2784,7 +2789,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
27842789

27852790
this.emit('update', {
27862791
editor: this,
2787-
transaction: transactionToApply,
2792+
transaction: effectiveTransaction,
27882793
});
27892794
}
27902795
}

packages/super-editor/src/editors/v1/core/commands/changeListLevel.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,10 @@ export function updateNumberingProperties(newNumberingProperties, paragraphNode,
117117
numberingProperties: newNumberingProperties ? { ...newNumberingProperties } : null,
118118
};
119119

120-
if (paragraphNode.attrs.paragraphProperties?.styleId === 'ListParagraph') {
121-
// Word's default list paragraph style
120+
// Only strip the default ListParagraph styleId when removing list formatting.
121+
// Keeping it on style swaps preserves the style's contextualSpacing, which Word
122+
// relies on to collapse the inter-paragraph gap between consecutive list items.
123+
if (!newNumberingProperties && paragraphNode.attrs.paragraphProperties?.styleId === 'ListParagraph') {
122124
newProperties.styleId = null;
123125
}
124126

packages/super-editor/src/editors/v1/core/commands/changeListLevel.test.js

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({
2020
calculateResolvedParagraphProperties: vi.fn((_, node, __) => node.attrs.paragraphProperties || {}),
2121
}));
2222

23-
import { changeListLevel } from './changeListLevel.js';
23+
import { changeListLevel, updateNumberingProperties } from './changeListLevel.js';
2424
import { findParentNode } from '@helpers/index.js';
2525
import { ListHelpers } from '@helpers/list-numbering-helpers.js';
2626

@@ -267,3 +267,58 @@ describe('changeListLevel', () => {
267267
expect(tr.setNodeMarkup.mock.calls.map(([pos]) => pos)).toEqual([0, 30, 60]);
268268
});
269269
});
270+
271+
describe('updateNumberingProperties', () => {
272+
/** @type {{ setNodeMarkup: ReturnType<typeof vi.fn> }} */
273+
let tr;
274+
275+
beforeEach(() => {
276+
tr = { setNodeMarkup: vi.fn() };
277+
});
278+
279+
// Word's `ListParagraph` style carries `<w:contextualSpacing/>`, which collapses
280+
// the inter-paragraph gap between consecutive list items. If we drop the styleId
281+
// on a style swap, that suppression stops applying and a vertical gap appears
282+
// between item 1 and item 2 — which is exactly what SD-2527 was reporting.
283+
it('preserves the ListParagraph styleId when migrating to a new numId', () => {
284+
const node = {
285+
type: { name: 'paragraph' },
286+
attrs: {
287+
paragraphProperties: {
288+
styleId: 'ListParagraph',
289+
numberingProperties: { numId: 1, ilvl: 0 },
290+
},
291+
numberingProperties: { numId: 1, ilvl: 0 },
292+
listRendering: {},
293+
},
294+
};
295+
296+
updateNumberingProperties({ numId: 2, ilvl: 0 }, node, 12, /* editor */ {}, tr);
297+
298+
expect(tr.setNodeMarkup).toHaveBeenCalledTimes(1);
299+
const [, , newAttrs] = tr.setNodeMarkup.mock.calls[0];
300+
expect(newAttrs.paragraphProperties.styleId).toBe('ListParagraph');
301+
expect(newAttrs.paragraphProperties.numberingProperties).toEqual({ numId: 2, ilvl: 0 });
302+
});
303+
304+
it('strips the ListParagraph styleId when removing list formatting', () => {
305+
const node = {
306+
type: { name: 'paragraph' },
307+
attrs: {
308+
paragraphProperties: {
309+
styleId: 'ListParagraph',
310+
numberingProperties: { numId: 1, ilvl: 0 },
311+
},
312+
numberingProperties: { numId: 1, ilvl: 0 },
313+
listRendering: {},
314+
},
315+
};
316+
317+
updateNumberingProperties(null, node, 12, /* editor */ {}, tr);
318+
319+
const [, , newAttrs] = tr.setNodeMarkup.mock.calls[0];
320+
expect(newAttrs.paragraphProperties.styleId).toBeNull();
321+
expect(newAttrs.paragraphProperties.numberingProperties).toBeNull();
322+
expect(newAttrs.listRendering).toBeNull();
323+
});
324+
});

0 commit comments

Comments
 (0)