Skip to content

Commit 25a6b41

Browse files
authored
fix(super-editor): handle splitBefore/splitAfter in whole-textblock rewrite branch (SD-2362) (#2766)
The replacesEntireTextblock branch in resolveStructuralRangeRewrite was using payload.blocks directly without applying splitBefore/splitAfter signals. Inputs like "\nAlpha" (one core block + splitBefore) hit the length <= 1 guard and fell back to inline insertion, producing a literal "\n" character in the text node instead of a structural paragraph split. Move the effectiveBlocks computation (with split signal handling) above both branches so the whole-textblock path uses the same logic the partial-range path already had. Add unit tests for all split-signal edge cases and integration tests for leading/trailing newlines in both direct and tracked modes.
1 parent 90def7f commit 25a6b41

3 files changed

Lines changed: 379 additions & 12 deletions

File tree

packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,284 @@ describe('executeCompiledPlan: text.rewrite style behavior', () => {
10231023
expect(tr.doc.child(0).textContent).toBe('Alpha\n\nBeta');
10241024
});
10251025

1026+
it('handles splitBefore with single core block on whole-textblock rewrite', () => {
1027+
const schema = new Schema({
1028+
nodes: {
1029+
doc: { content: 'block+' },
1030+
paragraph: { group: 'block', content: 'text*' },
1031+
text: { group: 'inline' },
1032+
},
1033+
});
1034+
1035+
const doc = schema.nodes.doc.create({}, [
1036+
schema.nodes.paragraph.create({}, schema.text('before')),
1037+
schema.nodes.paragraph.create({}, schema.text('hello world')),
1038+
schema.nodes.paragraph.create({}, schema.text('after')),
1039+
]);
1040+
1041+
let absFrom = -1;
1042+
doc.descendants((node, pos) => {
1043+
if (node.isText && node.text === 'hello world') {
1044+
absFrom = pos;
1045+
return false;
1046+
}
1047+
return undefined;
1048+
});
1049+
1050+
const state = EditorState.create({ schema, doc });
1051+
const tr = state.tr;
1052+
const editor = { state } as unknown as Editor;
1053+
1054+
mockedDeps.resolveInlineStyle.mockReturnValue([]);
1055+
1056+
const target = makeTarget({
1057+
absFrom,
1058+
absTo: absFrom + 'hello world'.length,
1059+
from: 0,
1060+
to: 'hello world'.length,
1061+
text: 'hello world',
1062+
capturedStyle: { runs: [], isUniform: true },
1063+
}) as any;
1064+
const step: TextRewriteStep = {
1065+
id: 'step-split-before',
1066+
op: 'text.rewrite',
1067+
where: { by: 'select', select: { type: 'text', pattern: 'hello world' }, require: 'exactlyOne' },
1068+
args: { replacement: { text: '\nAlpha' } },
1069+
};
1070+
1071+
const outcome = executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any);
1072+
1073+
expect(outcome).toEqual({ changed: true });
1074+
expect(tr.doc.childCount).toBe(4);
1075+
expect(tr.doc.child(0).textContent).toBe('before');
1076+
expect(tr.doc.child(1).textContent).toBe('');
1077+
expect(tr.doc.child(2).textContent).toBe('Alpha');
1078+
expect(tr.doc.child(3).textContent).toBe('after');
1079+
});
1080+
1081+
it('handles splitAfter with single core block on whole-textblock rewrite', () => {
1082+
const schema = new Schema({
1083+
nodes: {
1084+
doc: { content: 'block+' },
1085+
paragraph: { group: 'block', content: 'text*' },
1086+
text: { group: 'inline' },
1087+
},
1088+
});
1089+
1090+
const doc = schema.nodes.doc.create({}, [
1091+
schema.nodes.paragraph.create({}, schema.text('before')),
1092+
schema.nodes.paragraph.create({}, schema.text('hello world')),
1093+
schema.nodes.paragraph.create({}, schema.text('after')),
1094+
]);
1095+
1096+
let absFrom = -1;
1097+
doc.descendants((node, pos) => {
1098+
if (node.isText && node.text === 'hello world') {
1099+
absFrom = pos;
1100+
return false;
1101+
}
1102+
return undefined;
1103+
});
1104+
1105+
const state = EditorState.create({ schema, doc });
1106+
const tr = state.tr;
1107+
const editor = { state } as unknown as Editor;
1108+
1109+
mockedDeps.resolveInlineStyle.mockReturnValue([]);
1110+
1111+
const target = makeTarget({
1112+
absFrom,
1113+
absTo: absFrom + 'hello world'.length,
1114+
from: 0,
1115+
to: 'hello world'.length,
1116+
text: 'hello world',
1117+
capturedStyle: { runs: [], isUniform: true },
1118+
}) as any;
1119+
const step: TextRewriteStep = {
1120+
id: 'step-split-after',
1121+
op: 'text.rewrite',
1122+
where: { by: 'select', select: { type: 'text', pattern: 'hello world' }, require: 'exactlyOne' },
1123+
args: { replacement: { text: 'Alpha\n' } },
1124+
};
1125+
1126+
const outcome = executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any);
1127+
1128+
expect(outcome).toEqual({ changed: true });
1129+
expect(tr.doc.childCount).toBe(4);
1130+
expect(tr.doc.child(0).textContent).toBe('before');
1131+
expect(tr.doc.child(1).textContent).toBe('Alpha');
1132+
expect(tr.doc.child(2).textContent).toBe('');
1133+
expect(tr.doc.child(3).textContent).toBe('after');
1134+
});
1135+
1136+
it('handles both splitBefore and splitAfter with single core block on whole-textblock rewrite', () => {
1137+
const schema = new Schema({
1138+
nodes: {
1139+
doc: { content: 'block+' },
1140+
paragraph: { group: 'block', content: 'text*' },
1141+
text: { group: 'inline' },
1142+
},
1143+
});
1144+
1145+
const doc = schema.nodes.doc.create({}, [
1146+
schema.nodes.paragraph.create({}, schema.text('before')),
1147+
schema.nodes.paragraph.create({}, schema.text('hello world')),
1148+
schema.nodes.paragraph.create({}, schema.text('after')),
1149+
]);
1150+
1151+
let absFrom = -1;
1152+
doc.descendants((node, pos) => {
1153+
if (node.isText && node.text === 'hello world') {
1154+
absFrom = pos;
1155+
return false;
1156+
}
1157+
return undefined;
1158+
});
1159+
1160+
const state = EditorState.create({ schema, doc });
1161+
const tr = state.tr;
1162+
const editor = { state } as unknown as Editor;
1163+
1164+
mockedDeps.resolveInlineStyle.mockReturnValue([]);
1165+
1166+
const target = makeTarget({
1167+
absFrom,
1168+
absTo: absFrom + 'hello world'.length,
1169+
from: 0,
1170+
to: 'hello world'.length,
1171+
text: 'hello world',
1172+
capturedStyle: { runs: [], isUniform: true },
1173+
}) as any;
1174+
const step: TextRewriteStep = {
1175+
id: 'step-split-both',
1176+
op: 'text.rewrite',
1177+
where: { by: 'select', select: { type: 'text', pattern: 'hello world' }, require: 'exactlyOne' },
1178+
args: { replacement: { text: '\nAlpha\n' } },
1179+
};
1180+
1181+
const outcome = executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any);
1182+
1183+
expect(outcome).toEqual({ changed: true });
1184+
expect(tr.doc.childCount).toBe(5);
1185+
expect(tr.doc.child(0).textContent).toBe('before');
1186+
expect(tr.doc.child(1).textContent).toBe('');
1187+
expect(tr.doc.child(2).textContent).toBe('Alpha');
1188+
expect(tr.doc.child(3).textContent).toBe('');
1189+
expect(tr.doc.child(4).textContent).toBe('after');
1190+
});
1191+
1192+
it('handles splitBefore with multi-block replacement on whole-textblock rewrite', () => {
1193+
const schema = new Schema({
1194+
nodes: {
1195+
doc: { content: 'block+' },
1196+
paragraph: { group: 'block', content: 'text*' },
1197+
text: { group: 'inline' },
1198+
},
1199+
});
1200+
1201+
const doc = schema.nodes.doc.create({}, [
1202+
schema.nodes.paragraph.create({}, schema.text('before')),
1203+
schema.nodes.paragraph.create({}, schema.text('hello world')),
1204+
schema.nodes.paragraph.create({}, schema.text('after')),
1205+
]);
1206+
1207+
let absFrom = -1;
1208+
doc.descendants((node, pos) => {
1209+
if (node.isText && node.text === 'hello world') {
1210+
absFrom = pos;
1211+
return false;
1212+
}
1213+
return undefined;
1214+
});
1215+
1216+
const state = EditorState.create({ schema, doc });
1217+
const tr = state.tr;
1218+
const editor = { state } as unknown as Editor;
1219+
1220+
mockedDeps.resolveInlineStyle.mockReturnValue([]);
1221+
1222+
const target = makeTarget({
1223+
absFrom,
1224+
absTo: absFrom + 'hello world'.length,
1225+
from: 0,
1226+
to: 'hello world'.length,
1227+
text: 'hello world',
1228+
capturedStyle: { runs: [], isUniform: true },
1229+
}) as any;
1230+
const step: TextRewriteStep = {
1231+
id: 'step-split-before-multi',
1232+
op: 'text.rewrite',
1233+
where: { by: 'select', select: { type: 'text', pattern: 'hello world' }, require: 'exactlyOne' },
1234+
args: { replacement: { text: '\nAlpha\n\nBeta' } },
1235+
};
1236+
1237+
const outcome = executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any);
1238+
1239+
expect(outcome).toEqual({ changed: true });
1240+
expect(tr.doc.childCount).toBe(5);
1241+
expect(tr.doc.child(0).textContent).toBe('before');
1242+
expect(tr.doc.child(1).textContent).toBe('');
1243+
expect(tr.doc.child(2).textContent).toBe('Alpha');
1244+
expect(tr.doc.child(3).textContent).toBe('Beta');
1245+
expect(tr.doc.child(4).textContent).toBe('after');
1246+
});
1247+
1248+
it('handles splitAfter with multi-block replacement on whole-textblock rewrite', () => {
1249+
const schema = new Schema({
1250+
nodes: {
1251+
doc: { content: 'block+' },
1252+
paragraph: { group: 'block', content: 'text*' },
1253+
text: { group: 'inline' },
1254+
},
1255+
});
1256+
1257+
const doc = schema.nodes.doc.create({}, [
1258+
schema.nodes.paragraph.create({}, schema.text('before')),
1259+
schema.nodes.paragraph.create({}, schema.text('hello world')),
1260+
schema.nodes.paragraph.create({}, schema.text('after')),
1261+
]);
1262+
1263+
let absFrom = -1;
1264+
doc.descendants((node, pos) => {
1265+
if (node.isText && node.text === 'hello world') {
1266+
absFrom = pos;
1267+
return false;
1268+
}
1269+
return undefined;
1270+
});
1271+
1272+
const state = EditorState.create({ schema, doc });
1273+
const tr = state.tr;
1274+
const editor = { state } as unknown as Editor;
1275+
1276+
mockedDeps.resolveInlineStyle.mockReturnValue([]);
1277+
1278+
const target = makeTarget({
1279+
absFrom,
1280+
absTo: absFrom + 'hello world'.length,
1281+
from: 0,
1282+
to: 'hello world'.length,
1283+
text: 'hello world',
1284+
capturedStyle: { runs: [], isUniform: true },
1285+
}) as any;
1286+
const step: TextRewriteStep = {
1287+
id: 'step-split-after-multi',
1288+
op: 'text.rewrite',
1289+
where: { by: 'select', select: { type: 'text', pattern: 'hello world' }, require: 'exactlyOne' },
1290+
args: { replacement: { text: 'Alpha\n\nBeta\n' } },
1291+
};
1292+
1293+
const outcome = executeTextRewrite(editor, tr, target, step, { map: (pos: number) => pos } as any);
1294+
1295+
expect(outcome).toEqual({ changed: true });
1296+
expect(tr.doc.childCount).toBe(5);
1297+
expect(tr.doc.child(0).textContent).toBe('before');
1298+
expect(tr.doc.child(1).textContent).toBe('Alpha');
1299+
expect(tr.doc.child(2).textContent).toBe('Beta');
1300+
expect(tr.doc.child(3).textContent).toBe('');
1301+
expect(tr.doc.child(4).textContent).toBe('after');
1302+
});
1303+
10261304
it('keeps partial-range rewrites inline in a real transaction when replacement text stays within one block', () => {
10271305
const schema = new Schema({
10281306
nodes: {

packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,12 +1270,18 @@ function resolveStructuralRangeRewrite(
12701270
return null;
12711271
}
12721272

1273-
const replacementBlocks = payload.blocks;
1273+
const effectiveBlocks = [...payload.blocks];
1274+
if (payload.splitBefore) {
1275+
effectiveBlocks.unshift('');
1276+
}
1277+
if (payload.splitAfter) {
1278+
effectiveBlocks.push('');
1279+
}
12741280

12751281
if (replacesEntireTextblock) {
1276-
if (replacementBlocks.length <= 1) {
1282+
if (effectiveBlocks.length <= 1) {
12771283
debugTextRewrite('structural rewrite skipped: whole-textblock rewrite resolved to one block', {
1278-
replacementBlocks,
1284+
replacementBlocks: effectiveBlocks,
12791285
stepId: step.id,
12801286
});
12811287
return null;
@@ -1291,14 +1297,14 @@ function resolveStructuralRangeRewrite(
12911297
inlineRange,
12921298
replaceFrom,
12931299
replaceTo,
1294-
replacementBlockCount: replacementBlocks.length,
1300+
replacementBlockCount: effectiveBlocks.length,
12951301
openStart: 0,
12961302
openEnd: 0,
12971303
stepId: step.id,
12981304
});
12991305

13001306
return {
1301-
replacementBlocks,
1307+
replacementBlocks: effectiveBlocks,
13021308
paragraphAttrs: stripIdentityAttrs($from.node(textblockDepth).attrs as Record<string, unknown>),
13031309
replaceFrom,
13041310
replaceTo,
@@ -1311,13 +1317,6 @@ function resolveStructuralRangeRewrite(
13111317

13121318
const leadingWrappers = resolveInlineWrapperChainAt(doc, absFrom, textblockDepth);
13131319
const trailingWrappers = resolveInlineWrapperChainAt(doc, absTo, textblockDepth);
1314-
const effectiveBlocks = [...replacementBlocks];
1315-
if (payload.splitBefore) {
1316-
effectiveBlocks.unshift('');
1317-
}
1318-
if (payload.splitAfter) {
1319-
effectiveBlocks.push('');
1320-
}
13211320
const openStart = 1 + leadingWrappers.length;
13221321
const openEnd = 1 + trailingWrappers.length;
13231322
debugTextRewrite('structural rewrite enabled', {

0 commit comments

Comments
 (0)