Skip to content

Commit ec4cfc6

Browse files
committed
feat: Update mergeOverlappingDecorations to handle 0 width decoration
1 parent 9a429aa commit ec4cfc6

File tree

2 files changed

+210
-12
lines changed

2 files changed

+210
-12
lines changed

packages/test-case-component/src/helpers/decorations/mergeOverlappingDecorations.test.ts

Lines changed: 142 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,19 @@ describe("mergeOverlappingDecorations", () => {
1717
},
1818
];
1919
const result = mergeOverlappingDecorations(input);
20-
// Only the first decoration should be present, as there is no true overlap
21-
expect(result).toEqual([
22-
{
23-
start: { line: 0, character: 0 },
24-
end: { line: 0, character: 1 },
25-
properties: { class: "hat default" },
26-
alwaysWrap: true,
27-
},
28-
]);
20+
// The zero-width selection should be merged into the previous mark as selectionRight
21+
expect(result).toEqual(
22+
expect.arrayContaining([
23+
expect.objectContaining({
24+
start: { line: 0, character: 0 },
25+
end: { line: 0, character: 1 },
26+
properties: { class: expect.stringContaining("hat default selectionRight") },
27+
alwaysWrap: true,
28+
}),
29+
])
30+
);
31+
// Should not include the zero-width selection
32+
expect(result.some(d => d.properties?.class === "selection")).toBe(false);
2933
});
3034

3135
it("returns non-overlapping decorations unchanged", () => {
@@ -93,4 +97,133 @@ describe("mergeOverlappingDecorations", () => {
9397
properties: { class: "B" },
9498
});
9599
});
100+
101+
it("preserves zero-width (selection) decorations alongside others", () => {
102+
const input = [
103+
{
104+
start: { line: 0, character: 0 },
105+
end: { line: 0, character: 1 },
106+
properties: { class: "sourceMark" },
107+
alwaysWrap: true,
108+
},
109+
{
110+
start: { line: 0, character: 1 },
111+
end: { line: 0, character: 2 },
112+
properties: { class: "thatMark" },
113+
alwaysWrap: true,
114+
},
115+
{
116+
start: { line: 0, character: 2 },
117+
end: { line: 0, character: 2 }, // zero-width selection
118+
properties: { class: "selection" },
119+
alwaysWrap: true,
120+
},
121+
];
122+
const result = mergeOverlappingDecorations(input);
123+
// The zero-width selection should be merged into the previous mark as selectionRight
124+
expect(result).toEqual(
125+
expect.arrayContaining([
126+
expect.objectContaining({
127+
start: { line: 0, character: 0 },
128+
end: { line: 0, character: 1 },
129+
properties: { class: "sourceMark" },
130+
alwaysWrap: true,
131+
}),
132+
expect.objectContaining({
133+
start: { line: 0, character: 1 },
134+
end: { line: 0, character: 2 },
135+
properties: { class: expect.stringContaining("thatMark selectionRight") },
136+
alwaysWrap: true,
137+
}),
138+
])
139+
);
140+
// Should not include the zero-width selection
141+
expect(result.some(d => d.properties?.class === "selection")).toBe(false);
142+
});
143+
144+
it("merges zero-width selection at end into previous mark as selectionRight", () => {
145+
const input = [
146+
{
147+
start: { line: 0, character: 0 },
148+
end: { line: 0, character: 1 },
149+
properties: { class: "sourceMark" },
150+
alwaysWrap: true,
151+
},
152+
{
153+
start: { line: 0, character: 1 },
154+
end: { line: 0, character: 2 },
155+
properties: { class: "thatMark" },
156+
alwaysWrap: true,
157+
},
158+
{
159+
start: { line: 0, character: 2 },
160+
end: { line: 0, character: 2 }, // zero-width selection
161+
properties: { class: "selection" },
162+
alwaysWrap: true,
163+
},
164+
];
165+
const result = mergeOverlappingDecorations(input);
166+
// The zero-width selection should be deleted, and the thatMark should have selectionRight
167+
expect(result).toEqual(
168+
expect.arrayContaining([
169+
expect.objectContaining({
170+
start: { line: 0, character: 0 },
171+
end: { line: 0, character: 1 },
172+
properties: { class: "sourceMark" },
173+
alwaysWrap: true,
174+
}),
175+
expect.objectContaining({
176+
start: { line: 0, character: 1 },
177+
end: { line: 0, character: 2 },
178+
properties: { class: expect.stringContaining("thatMark selectionRight") },
179+
alwaysWrap: true,
180+
}),
181+
])
182+
);
183+
// Should not include the zero-width selection
184+
expect(result.some(d => d.properties?.class === "selection")).toBe(false);
185+
});
186+
187+
it("merges zero-width selection at start into next mark as selectionLeft", () => {
188+
const input = [
189+
{
190+
start: { line: 0, character: 0 },
191+
end: { line: 0, character: 1 },
192+
properties: { class: "sourceMark" },
193+
alwaysWrap: true,
194+
},
195+
{
196+
start: { line: 0, character: 1 },
197+
end: { line: 0, character: 2 },
198+
properties: { class: "thatMark" },
199+
alwaysWrap: true,
200+
},
201+
{
202+
start: { line: 0, character: 1 },
203+
end: { line: 0, character: 1 }, // zero-width selection at start of thatMark
204+
properties: { class: "selection" },
205+
alwaysWrap: true,
206+
},
207+
];
208+
const result = mergeOverlappingDecorations(input);
209+
// The zero-width selection should be merged into the next mark as selectionLeft
210+
expect(result).toEqual(
211+
expect.arrayContaining([
212+
expect.objectContaining({
213+
start: { line: 0, character: 0 },
214+
end: { line: 0, character: 1 },
215+
properties: { class: "sourceMark" },
216+
alwaysWrap: true,
217+
}),
218+
expect.objectContaining({
219+
start: { line: 0, character: 1 },
220+
end: { line: 0, character: 2 },
221+
properties: { class: expect.stringContaining("thatMark selectionLeft") },
222+
alwaysWrap: true,
223+
}),
224+
])
225+
);
226+
// Should not include the zero-width selection
227+
expect(result.some(d => d.properties?.class === "selection")).toBe(false);
228+
});
96229
});

packages/test-case-component/src/helpers/decorations/mergeOverlappingDecorations.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,20 @@ export function mergeOverlappingDecorations(decorations: DecorationItem[]): Deco
1414
return obj && typeof obj.line === "number" && typeof obj.character === "number";
1515
}
1616

17+
// Always include zero-width decorations (start == end)
18+
const zeroWidth = decorations.filter(
19+
d => isPosition(d.start) && isPosition(d.end) && d.start.line === d.end.line && d.start.character === d.end.character
20+
);
21+
// Remove zero-width from main processing
22+
const nonZeroWidth = decorations.filter(
23+
d => !(
24+
isPosition(d.start) && isPosition(d.end) && d.start.line === d.end.line && d.start.character === d.end.character
25+
)
26+
);
27+
1728
// Collect all unique boundary points
1829
const points: Position[] = [];
19-
for (const deco of decorations) {
30+
for (const deco of nonZeroWidth) {
2031
if (isPosition(deco.start) && isPosition(deco.end)) {
2132
points.push(deco.start, deco.end);
2233
}
@@ -35,7 +46,7 @@ export function mergeOverlappingDecorations(decorations: DecorationItem[]): Deco
3546
const segStart = uniquePoints[i];
3647
const segEnd = uniquePoints[i + 1];
3748
// Find all decorations covering this segment
38-
const covering = decorations.filter(d =>
49+
const covering = nonZeroWidth.filter(d =>
3950
isPosition(d.start) && isPosition(d.end) &&
4051
(d.start.line < segEnd.line || (d.start.line === segEnd.line && d.start.character < segEnd.character)) &&
4152
(d.end.line > segStart.line || (d.end.line === segStart.line && d.end.character > segStart.character))
@@ -66,5 +77,59 @@ export function mergeOverlappingDecorations(decorations: DecorationItem[]): Deco
6677
});
6778
}
6879
}
69-
return result;
80+
// Instead of outputting a zero-width selection at the start or end of a range, merge it into the next or previous mark as selectionLeft/selectionRight
81+
const endPosToIdx = new Map<string, number>(); // end position -> index in result
82+
const startPosToIdx = new Map<string, number>(); // start position -> index in result
83+
for (let i = 0; i < result.length; ++i) {
84+
const deco = result[i];
85+
if (isPosition(deco.end)) {
86+
endPosToIdx.set(`${deco.end.line}:${deco.end.character}`, i);
87+
}
88+
if (isPosition(deco.start)) {
89+
startPosToIdx.set(`${deco.start.line}:${deco.start.character}`, i);
90+
}
91+
}
92+
function handleZeroWidthDecoration(
93+
d: DecorationItem,
94+
result: DecorationItem[],
95+
endPosToIdx: Map<string, number>,
96+
startPosToIdx: Map<string, number>
97+
): boolean {
98+
const className = d.properties?.class;
99+
if (className === "selection") {
100+
const pos = isPosition(d.start) ? `${d.start.line}:${d.start.character}` : String(d.start);
101+
const prevIdx = endPosToIdx.get(pos);
102+
const nextIdx = startPosToIdx.get(pos);
103+
// Prioritize merging into the next mark (selectionLeft) before previous (selectionRight)
104+
if (nextIdx !== undefined) {
105+
// Merge selectionLeft into the next mark
106+
const next = result[nextIdx];
107+
const nextClass = next.properties?.class ?? "";
108+
const newClass = typeof nextClass === "string" && nextClass.split(" ").includes("selectionLeft")
109+
? nextClass
110+
: (typeof nextClass === "string" ? (nextClass + " selectionLeft").trim() : "selectionLeft");
111+
result[nextIdx] = { ...next, properties: { ...next.properties, class: newClass } };
112+
return true; // handled
113+
} else if (prevIdx !== undefined) {
114+
// Merge selectionRight into the previous mark
115+
const prev = result[prevIdx];
116+
const prevClass = prev.properties?.class ?? "";
117+
const newClass = typeof prevClass === "string" && prevClass.split(" ").includes("selectionRight")
118+
? prevClass
119+
: (typeof prevClass === "string" ? (prevClass + " selectionRight").trim() : "selectionRight");
120+
result[prevIdx] = { ...prev, properties: { ...prev.properties, class: newClass } };
121+
return true; // handled
122+
}
123+
return false; // not handled
124+
} else {
125+
throw new Error(`Unhandled zero-width decoration class: ${className}`);
126+
}
127+
}
128+
const filteredZeroWidth: DecorationItem[] = [];
129+
for (const d of zeroWidth) {
130+
if (!handleZeroWidthDecoration(d, result, endPosToIdx, startPosToIdx)) {
131+
filteredZeroWidth.push(d);
132+
}
133+
}
134+
return result.concat(filteredZeroWidth);
70135
}

0 commit comments

Comments
 (0)