Skip to content

Commit 9d9da3a

Browse files
committed
fix: handle overlapping ranges of inline styles and entities corrently
I have refactored the current implementation to keep track of correct tag nesting using a stack data structure. Also I fixed some of the code duplication using the `openTag`, `closeTag` helper functions.
1 parent c0b54e4 commit 9d9da3a

1 file changed

Lines changed: 101 additions & 97 deletions

File tree

src/draft-to-markdown.js

Lines changed: 101 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -254,89 +254,115 @@ function renderBlock(block, index, rawDraftObject, options) {
254254
}
255255
}
256256

257-
// Render text within content, along with any inline styles/entities
258-
Array.from(block.text).some(function (character, characterIndex) {
259-
// Close any entity tags that need closing
260-
block.entityRanges.forEach(function (range, rangeIndex) {
261-
if (range.offset + range.length === characterIndex) {
262-
var entity = rawDraftObject.entityMap[range.key];
263-
if (customEntityItems[entity.type] || EntityItems[entity.type]) {
264-
markdownString += (customEntityItems[entity.type] || EntityItems[entity.type]).close(entity);
265-
}
257+
// A stack to keep track of open tags
258+
var openTags = [];
259+
260+
function openTag(tag) {
261+
openTags.push(tag);
262+
263+
if (tag.style) {
264+
// Open inline tag
265+
266+
if (customStyleItems[tag.style] || StyleItems[tag.style]) {
267+
var styleToAdd = (
268+
customStyleItems[tag.style] || StyleItems[tag.style]
269+
).open();
270+
markdownToAdd.push({
271+
type: 'style',
272+
style: tag,
273+
value: styleToAdd
274+
});
266275
}
267-
});
276+
} else {
277+
// Open entity tag
268278

269-
// Close any inline tags that need closing
270-
openInlineStyles.forEach(function (style, styleIndex) {
271-
if (style.offset + style.length === characterIndex) {
272-
if ((customStyleItems[style.style] || StyleItems[style.style])) {
273-
var styleIndex = openInlineStyles.indexOf(style);
274-
// Handle nested case - close any open inline styles before closing the parent
275-
if (styleIndex > -1 && styleIndex !== openInlineStyles.length - 1) {
276-
for (var i = openInlineStyles.length - 1; i !== styleIndex; i--) {
277-
var styleItem = (customStyleItems[openInlineStyles[i].style] || StyleItems[openInlineStyles[i].style]);
278-
if (styleItem) {
279-
var trailingWhitespace = TRAILING_WHITESPACE.exec(markdownString);
280-
markdownString = markdownString.slice(0, markdownString.length - trailingWhitespace[0].length);
281-
markdownString += styleItem.close();
282-
markdownString += trailingWhitespace[0];
283-
}
284-
}
285-
}
279+
var entity = rawDraftObject.entityMap[tag.key];
280+
if (customEntityItems[entity.type] || EntityItems[entity.type]) {
281+
var entityToAdd = (
282+
customEntityItems[entity.type] || EntityItems[entity.type]
283+
).open(entity);
284+
markdownToAdd.push({
285+
type: 'entity',
286+
value: entityToAdd
287+
});
288+
}
289+
}
290+
}
286291

287-
// Close the actual inline style being closed
288-
// Have to trim whitespace first and then re-add after because markdown can't handle leading/trailing whitespace
289-
var trailingWhitespace = TRAILING_WHITESPACE.exec(markdownString);
290-
markdownString = markdownString.slice(0, markdownString.length - trailingWhitespace[0].length);
291-
292-
markdownString += (customStyleItems[style.style] || StyleItems[style.style]).close();
293-
markdownString += trailingWhitespace[0];
294-
295-
// Handle nested case - reopen any inline styles after closing the parent
296-
if (styleIndex > -1 && styleIndex !== openInlineStyles.length - 1) {
297-
for (var i = openInlineStyles.length - 1; i !== styleIndex; i--) {
298-
var styleItem = (customStyleItems[openInlineStyles[i].style] || StyleItems[openInlineStyles[i].style]);
299-
if (styleItem && openInlineStyles[i].offset + openInlineStyles[i].length > characterIndex) {
300-
markdownString += styleItem.open();
301-
} else {
302-
openInlineStyles.splice(i, 1);
303-
}
304-
}
305-
}
292+
function closeTag(tag) {
293+
const popped = openTags.pop();
294+
if (tag !== popped) {
295+
throw new Error(
296+
'Invariant violation: Cannot close a tag before all inner tags have been closed'
297+
);
298+
}
306299

307-
openInlineStyles.splice(styleIndex, 1);
308-
}
300+
if (tag.style) {
301+
// Close inline tag
302+
303+
if (customStyleItems[tag.style] || StyleItems[tag.style]) {
304+
// Have to trim whitespace first and then re-add after because markdown can't handle leading/trailing whitespace
305+
var trailingWhitespace = TRAILING_WHITESPACE.exec(markdownString);
306+
markdownString = markdownString.slice(
307+
0,
308+
markdownString.length - trailingWhitespace[0].length
309+
);
310+
311+
markdownString += (
312+
customStyleItems[tag.style] || StyleItems[tag.style]
313+
).close();
314+
markdownString += trailingWhitespace[0];
309315
}
310-
});
316+
} else {
317+
// Close entity tag
311318

312-
// Open any inline tags that need opening
313-
block.inlineStyleRanges.forEach(function (style, styleIndex) {
314-
if (style.offset === characterIndex) {
315-
if ((customStyleItems[style.style] || StyleItems[style.style])) {
316-
var styleToAdd = (customStyleItems[style.style] || StyleItems[style.style]).open();
317-
markdownToAdd.push({
318-
type: 'style',
319-
style: style,
320-
value: styleToAdd
321-
});
322-
}
319+
var entity = rawDraftObject.entityMap[tag.key];
320+
if (customEntityItems[entity.type] || EntityItems[entity.type]) {
321+
markdownString += (
322+
customEntityItems[entity.type] || EntityItems[entity.type]
323+
).close(entity);
323324
}
324-
});
325+
}
326+
}
325327

326-
// Open any entity tags that need opening
327-
block.entityRanges.forEach(function (range, rangeIndex) {
328-
if (range.offset === characterIndex) {
329-
var entity = rawDraftObject.entityMap[range.key];
330-
if (customEntityItems[entity.type] || EntityItems[entity.type]) {
331-
var entityToAdd = (customEntityItems[entity.type] || EntityItems[entity.type]).open(entity);
332-
markdownToAdd.push({
333-
type: 'entity',
334-
value: entityToAdd
335-
});
336-
}
328+
const compareTagsLastCloseFirst = (a, b) =>
329+
b.offset + b.length - (a.offset + a.length);
330+
331+
// reverse array without mutating the original
332+
const reverse = (array) => array.concat().reverse();
333+
334+
// Render text within content, along with any inline styles/entities
335+
Array.from(block.text).some(function (character, characterIndex) {
336+
// Close any tags that need closing, starting from top of the stack
337+
reverse(openTags).forEach(function (tag) {
338+
if (tag.offset + tag.length === characterIndex) {
339+
// Take all tags stacked on top of the current one, meaning they opened after it.
340+
// Since they have not been popped, they'll close only later. So we need to split them.
341+
var tagsToSplit = openTags.slice(openTags.indexOf(tag) + 1);
342+
343+
// Close in reverse order as they were opened
344+
reverse(tagsToSplit).forEach(closeTag);
345+
346+
// Now we can close the current tag
347+
closeTag(tag);
348+
349+
// Reopen split tags, ordered so that tags that close last open first
350+
tagsToSplit.sort(compareTagsLastCloseFirst).forEach(openTag);
337351
}
338352
});
339353

354+
// Open any tags that need opening, using the correct nesting order.
355+
var inlineTagsToOpen = block.inlineStyleRanges.filter(
356+
(tag) => tag.offset === characterIndex
357+
);
358+
var entityTagsToOpen = block.entityRanges.filter(
359+
(tag) => tag.offset === characterIndex
360+
);
361+
inlineTagsToOpen
362+
.concat(entityTagsToOpen)
363+
.sort(compareTagsLastCloseFirst)
364+
.forEach(openTag);
365+
340366
// These are all the opening entity and style types being added to the markdown string for this loop
341367
// we store in an array and add here because if the character is WS character, we want to hang onto it and not apply it until the next non-whitespace
342368
// character before adding the markdown, since markdown doesn’t play nice with leading whitespace (eg '** bold**' is no good, whereas ' **bold**' is good.)
@@ -345,18 +371,11 @@ function renderBlock(block, index, rawDraftObject, options) {
345371
return item.value;
346372
}).join('');
347373

348-
markdownToAdd.forEach(function (item) {
349-
if (item.type === 'style') {
350-
// We hang on to this because we may need to close it early and then re-open if there are nested styles being opened and closed.
351-
openInlineStyles.push(item.style);
352-
}
353-
});
354-
355374
markdownToAdd = [];
356375
}
357376

358377
if (block.type !== 'code-block' && escapeMarkdownCharacters) {
359-
let insideInlineCodeStyle = openInlineStyles.find((style) => style.style === 'CODE');
378+
let insideInlineCodeStyle = openTags.find((style) => style.style === 'CODE');
360379

361380
if (insideInlineCodeStyle) {
362381
// Todo - The syntax to escape backtics when inside backtic code already is to use MORE backtics wrapping.
@@ -434,23 +453,8 @@ function renderBlock(block, index, rawDraftObject, options) {
434453
}
435454
});
436455

437-
// Close any remaining entity tags
438-
block.entityRanges.forEach(function (range, rangeIndex) {
439-
if (range.offset + range.length === Array.from(block.text).length) {
440-
var entity = rawDraftObject.entityMap[range.key];
441-
if (customEntityItems[entity.type] || EntityItems[entity.type]) {
442-
markdownString += (customEntityItems[entity.type] || EntityItems[entity.type]).close(entity);
443-
}
444-
}
445-
});
446-
447-
// Close any remaining inline tags (if an inline tag ends at the very last char, we won't catch it inside the loop)
448-
openInlineStyles.reverse().forEach(function (style) {
449-
var trailingWhitespace = TRAILING_WHITESPACE.exec(markdownString);
450-
markdownString = markdownString.slice(0, markdownString.length - trailingWhitespace[0].length);
451-
markdownString += (customStyleItems[style.style] || StyleItems[style.style]).close();
452-
markdownString += trailingWhitespace[0];
453-
});
456+
// Finally, close all remaining open tags
457+
reverse(openTags).forEach(closeTag);
454458

455459
// Close block level item
456460
if (customStyleItems[type] || StyleItems[type]) {

0 commit comments

Comments
 (0)