Skip to content

Commit bae68ee

Browse files
committed
feat(selection): implement wrapInTag method to wrap selected fragments in a <font> element
1 parent d0f8c2c commit bae68ee

4 files changed

Lines changed: 284 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
> - :house: [Internal]
1010
> - :nail_care: [Polish]
1111

12-
## Unreleased
12+
## 4.13.1
1313

1414
#### :rocket: New Feature
1515

@@ -19,6 +19,10 @@
1919

2020
- **Storage**: `LocalStorageProvider.delete(key)` removed the entire storage scope (every key sharing the same `rootKey`/suffix) instead of just the requested key — `delete` behaved identically to `clear`. It now reads the JSON blob, drops only that key and writes the rest back. Affects `Jodit.modules.Storage`/`buffer`/`storage` and the `@persistent` decorator when a single key is deleted.
2121

22+
#### :house: Internal
23+
24+
- **Selection**: `Select.wrapInTagGen` no longer relies on the browser's `document.execCommand('fontSize', false, '7')` to split a non-collapsed selection into wrappable inline fragments. It now uses a pure-DOM implementation that splits the boundary text nodes and wraps every contiguous run of selected inline content (grouped per block) into a `<font>` element. This removes one more dependency on the deprecated `execCommand` API and makes selection wrapping (`commitStyle`, bold/italic/font/color, `wrapInTag`) behave identically across Chrome and Firefox. No public API or output change; covered by new `wrapInTag` tests.
25+
2226
## 4.12.42
2327

2428
#### :bug: Bug Fix

src/core/selection/selection.test.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,4 +1032,97 @@ describe('Selection Module Tests', function () {
10321032
});
10331033
});
10341034
});
1035+
1036+
describe('wrapInTag', () => {
1037+
// Directly exercises the pure-DOM selection fragmentation that
1038+
// replaced the native `execCommand('fontsize', false, '7')` trick.
1039+
// `<span>` is used as a neutral, easy-to-read wrapper tag.
1040+
[
1041+
{
1042+
name: 'Whole text of a block',
1043+
from: '<p>|test|</p>',
1044+
to: '<p><span>test</span></p>'
1045+
},
1046+
{
1047+
name: 'Middle of a single text node (split at both ends)',
1048+
from: '<p>a|bc|d</p>',
1049+
to: '<p>a<span>bc</span>d</p>'
1050+
},
1051+
{
1052+
name: 'From the start of a text node',
1053+
from: '<p>|ab|cd</p>',
1054+
to: '<p><span>ab</span>cd</p>'
1055+
},
1056+
{
1057+
name: 'To the end of a text node',
1058+
from: '<p>ab|cd|</p>',
1059+
to: '<p>ab<span>cd</span></p>'
1060+
},
1061+
{
1062+
name: 'Partially inside a nested inline element',
1063+
from: '<p>a<strong>b|c|d</strong>e</p>',
1064+
to: '<p>a<strong>b<span>c</span>d</strong>e</p>'
1065+
},
1066+
{
1067+
name: 'Around a whole inline element with text on both sides',
1068+
from: '<p>a|b<strong>cd</strong>e|f</p>',
1069+
to: '<p>a<span>b<strong>cd</strong>e</span>f</p>'
1070+
},
1071+
{
1072+
name: 'Across two sibling inline elements (one run per parent)',
1073+
from: '<p><em>a|b</em><strong>c|d</strong></p>',
1074+
to: '<p><em>a<span>b</span></em><strong><span>c</span>d</strong></p>'
1075+
},
1076+
{
1077+
name: 'Two fully selected blocks (one wrapper per block)',
1078+
from: '<p>|one</p><p>two|</p>',
1079+
to: '<p><span>one</span></p><p><span>two</span></p>'
1080+
},
1081+
{
1082+
name: 'Selection crossing a block boundary',
1083+
from: '<p>on|e</p><p>t|wo</p>',
1084+
to: '<p>on<span>e</span></p><p><span>t</span>wo</p>'
1085+
}
1086+
].forEach(({ name, from, to }) => {
1087+
describe(name, () => {
1088+
it('Should wrap every selected fragment in the tag', () => {
1089+
const editor = getJodit();
1090+
editor.value = from;
1091+
setCursorToChar(editor);
1092+
1093+
editor.s.wrapInTag('span');
1094+
1095+
expect(sortAttributes(editor.value)).equals(to);
1096+
});
1097+
});
1098+
});
1099+
1100+
describe('Callback form', () => {
1101+
it('Should call the callback for every selected fragment', () => {
1102+
const editor = getJodit();
1103+
editor.value = '<p>on|e</p><p>t|wo</p>';
1104+
setCursorToChar(editor);
1105+
1106+
const chunks = [];
1107+
editor.s.wrapInTag(font => {
1108+
chunks.push(font.textContent);
1109+
});
1110+
1111+
expect(chunks).deep.equals(['e', 't']);
1112+
});
1113+
});
1114+
1115+
describe('Return value', () => {
1116+
it('Should return the created wrapper elements', () => {
1117+
const editor = getJodit();
1118+
editor.value = '<p>|one</p><p>two|</p>';
1119+
setCursorToChar(editor);
1120+
1121+
const result = editor.s.wrapInTag('span');
1122+
1123+
expect(result.length).equals(2);
1124+
expect(result.every(el => el.tagName === 'SPAN')).is.true;
1125+
});
1126+
});
1127+
});
10351128
});

src/core/selection/selection.ts

Lines changed: 185 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,190 @@ export class Selection implements ISelect {
12331233
return '';
12341234
}
12351235

1236+
/**
1237+
* Splits the boundaries of the current selection and wraps every
1238+
* contiguous run of selected inline content (grouped by block) into a
1239+
* `<font>` element, returning those wrappers in document order.
1240+
*
1241+
* This is a pure-DOM replacement for the old
1242+
* `nativeExecCommand('fontsize', false, '7')` trick which relied on the
1243+
* browser to split the selection into `<font size="7">` fragments.
1244+
*/
1245+
private __wrapSelectionFragments(): HTMLElement[] {
1246+
const range = this.range;
1247+
1248+
this.__splitSelectionBoundaries(range);
1249+
1250+
let root: Nullable<Node> = range.commonAncestorContainer;
1251+
1252+
if (Dom.isText(root)) {
1253+
root = root.parentNode;
1254+
}
1255+
1256+
if (!root) {
1257+
return [];
1258+
}
1259+
1260+
return this.__wrapSelectionRuns(
1261+
this.__collectContainedNodes(root, range)
1262+
);
1263+
}
1264+
1265+
/**
1266+
* Splits the text nodes at both ends of the range so that its boundaries
1267+
* always fall between nodes. Afterwards every node inside the range is
1268+
* fully (not partially) selected.
1269+
*/
1270+
private __splitSelectionBoundaries(range: Range): void {
1271+
let { startContainer, endContainer } = range;
1272+
let { startOffset, endOffset } = range;
1273+
1274+
if (
1275+
Dom.isText(endContainer) &&
1276+
endOffset > 0 &&
1277+
endOffset < (endContainer.nodeValue?.length ?? 0)
1278+
) {
1279+
endContainer.splitText(endOffset);
1280+
endOffset = endContainer.nodeValue?.length ?? endOffset;
1281+
}
1282+
1283+
if (
1284+
Dom.isText(startContainer) &&
1285+
startOffset > 0 &&
1286+
startOffset < (startContainer.nodeValue?.length ?? 0)
1287+
) {
1288+
const middle = startContainer.splitText(startOffset);
1289+
1290+
// Selection located inside a single text node - the tail we just
1291+
// cut off is the actual selected fragment.
1292+
if (startContainer === endContainer) {
1293+
endContainer = middle;
1294+
endOffset = middle.nodeValue?.length ?? 0;
1295+
}
1296+
1297+
startContainer = middle;
1298+
startOffset = 0;
1299+
}
1300+
1301+
range.setStart(startContainer, startOffset);
1302+
range.setEnd(endContainer, endOffset);
1303+
1304+
// Normalize text-edge boundaries (e.g. `(text, 0)` or `(text, length)`)
1305+
// to the element level so that containment checks based on
1306+
// `selectNode()` treat a fully selected text node as contained.
1307+
this.__normalizeRangeBoundary(range, true);
1308+
this.__normalizeRangeBoundary(range, false);
1309+
}
1310+
1311+
private __normalizeRangeBoundary(range: Range, atStart: boolean): void {
1312+
const container = atStart ? range.startContainer : range.endContainer;
1313+
1314+
if (!Dom.isText(container)) {
1315+
return;
1316+
}
1317+
1318+
const offset = atStart ? range.startOffset : range.endOffset;
1319+
const length = container.nodeValue?.length ?? 0;
1320+
1321+
if (offset === 0) {
1322+
atStart
1323+
? range.setStartBefore(container)
1324+
: range.setEndBefore(container);
1325+
} else if (offset >= length) {
1326+
atStart
1327+
? range.setStartAfter(container)
1328+
: range.setEndAfter(container);
1329+
}
1330+
}
1331+
1332+
/**
1333+
* Collects the highest-level nodes that are completely inside the range,
1334+
* descending into nodes that are only partially selected.
1335+
*/
1336+
private __collectContainedNodes(root: Node, range: Range): Node[] {
1337+
const result: Node[] = [];
1338+
1339+
toArray(root.childNodes).forEach(child => {
1340+
if (this.__isFullyContained(range, child)) {
1341+
result.push(child);
1342+
} else if (child.childNodes.length && range.intersectsNode(child)) {
1343+
result.push(...this.__collectContainedNodes(child, range));
1344+
}
1345+
});
1346+
1347+
return result;
1348+
}
1349+
1350+
private __isFullyContained(range: Range, node: Node): boolean {
1351+
const nodeRange = this.createRange();
1352+
nodeRange.selectNode(node);
1353+
1354+
return (
1355+
range.compareBoundaryPoints(Range.START_TO_START, nodeRange) <= 0 &&
1356+
range.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0
1357+
);
1358+
}
1359+
1360+
/**
1361+
* Wraps every contiguous run of selected inline siblings into a `<font>`
1362+
* element. Block-level nodes are never wrapped themselves - their inline
1363+
* content is wrapped instead, keeping every `<font>` inside a single block.
1364+
*/
1365+
private __wrapSelectionRuns(nodes: Node[]): HTMLElement[] {
1366+
const fonts: HTMLElement[] = [];
1367+
let run: Node[] = [];
1368+
1369+
const flush = (): void => {
1370+
if (run.length) {
1371+
fonts.push(this.__wrapRunInFont(run));
1372+
run = [];
1373+
}
1374+
};
1375+
1376+
nodes.forEach(node => {
1377+
if (Dom.isElement(node) && this.__isOrContainsBlock(node)) {
1378+
flush();
1379+
fonts.push(
1380+
...this.__wrapSelectionRuns(toArray(node.childNodes))
1381+
);
1382+
} else {
1383+
if (
1384+
run.length &&
1385+
run[run.length - 1].parentNode !== node.parentNode
1386+
) {
1387+
flush();
1388+
}
1389+
1390+
run.push(node);
1391+
}
1392+
});
1393+
1394+
flush();
1395+
1396+
return fonts;
1397+
}
1398+
1399+
private __isOrContainsBlock(node: Node): boolean {
1400+
if (Dom.isBlock(node)) {
1401+
return true;
1402+
}
1403+
1404+
return toArray(node.childNodes).some(child =>
1405+
this.__isOrContainsBlock(child)
1406+
);
1407+
}
1408+
1409+
private __wrapRunInFont(run: Node[]): HTMLElement {
1410+
const font = this.j.createInside.element('font');
1411+
const [first] = run;
1412+
1413+
first.parentNode?.insertBefore(font, first);
1414+
1415+
run.forEach(node => font.appendChild(node));
1416+
1417+
return font;
1418+
}
1419+
12361420
/**
12371421
* Wrap all selected fragments inside Tag or apply some callback
12381422
*/
@@ -1255,24 +1439,7 @@ export class Selection implements ISelect {
12551439
return;
12561440
}
12571441

1258-
// fix issue https://github.com/xdan/jodit/issues/65
1259-
$$('*[style*=font-size]', this.area).forEach(elm => {
1260-
attr(elm, 'data-font-size', elm.style.fontSize.toString());
1261-
elm.style.removeProperty('font-size');
1262-
});
1263-
1264-
this.j.nativeExecCommand('fontsize', false, '7');
1265-
1266-
$$('*[data-font-size]', this.area).forEach(elm => {
1267-
const fontSize = attr(elm, 'data-font-size');
1268-
1269-
if (fontSize) {
1270-
elm.style.fontSize = fontSize;
1271-
attr(elm, 'data-font-size', null);
1272-
}
1273-
});
1274-
1275-
const elms = $$('font[size="7"]', this.area);
1442+
const elms = this.__wrapSelectionFragments();
12761443

12771444
for (const font of elms) {
12781445
const { firstChild, lastChild } = font;

test/loader.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)