Skip to content

Commit 2b0c8d1

Browse files
authored
fix(xhs): 让话题追加到正文末尾
- 修复小红书正文编辑器光标定位,确保话题追加到正文末尾 - 增加回归测试,避免话题插入正文中间或产生空段落
1 parent 8b3a0b0 commit 2b0c8d1

2 files changed

Lines changed: 145 additions & 4 deletions

File tree

internal/xhs/publisher.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ func (p *rodPage) FillContent(ctx context.Context, content string, tags []string
176176
}
177177
}
178178
if text != "" && len(topicTags) > 0 {
179+
if err := focusEditableAtEnd(field); err != nil {
180+
return fmt.Errorf("focus editor: %w", err)
181+
}
179182
if err := rodTry(func() {
180183
field.MustInput("\n")
181184
}); err != nil {
@@ -190,12 +193,26 @@ func (p *rodPage) FillContent(ctx context.Context, content string, tags []string
190193
return nil
191194
}
192195

196+
func focusEditableAtEnd(field *rod.Element) error {
197+
return rodTry(func() {
198+
field.MustEval(`() => {
199+
const editor = this;
200+
editor.focus();
201+
const selection = window.getSelection();
202+
if (!selection) return;
203+
const range = document.createRange();
204+
range.selectNodeContents(editor);
205+
range.collapse(false);
206+
selection.removeAllRanges();
207+
selection.addRange(range);
208+
}`)
209+
})
210+
}
211+
193212
func (p *rodPage) inputTopicByKeyboard(field *rod.Element, tag string) error {
194213
timeouts := p.effectiveTimeouts()
195-
if err := rodTry(func() {
196-
field.MustClick()
197-
}); err != nil {
198-
return fmt.Errorf("click editor: %w", err)
214+
if err := focusEditableAtEnd(field); err != nil {
215+
return fmt.Errorf("focus editor: %w", err)
199216
}
200217
if err := p.typeTopicTrigger(); err != nil {
201218
return fmt.Errorf("type topic trigger: %w", err)

internal/xhs/publisher_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,130 @@ func TestFillContentAcceptsHighlightedTopicNode(t *testing.T) {
17241724
}
17251725
}
17261726

1727+
func TestFillContentAppendsTopicsAfterExistingText(t *testing.T) {
1728+
page := testPage(t)
1729+
1730+
html := `<!doctype html>
1731+
<html>
1732+
<head><meta charset="utf-8"></head>
1733+
<body>
1734+
<div class="tiptap-container">
1735+
<div contenteditable="true" role="textbox" class="tiptap ProseMirror" tabindex="0"></div>
1736+
</div>
1737+
<script>
1738+
const editor = document.querySelector('.tiptap.ProseMirror');
1739+
let pendingTopic = '';
1740+
let spaceCount = 0;
1741+
1742+
function placeCaretAfterText(needle) {
1743+
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
1744+
while (walker.nextNode()) {
1745+
const node = walker.currentNode;
1746+
const index = (node.nodeValue || '').indexOf(needle);
1747+
if (index < 0) continue;
1748+
const range = document.createRange();
1749+
range.setStart(node, index + needle.length);
1750+
range.collapse(true);
1751+
const selection = window.getSelection();
1752+
selection.removeAllRanges();
1753+
selection.addRange(range);
1754+
return true;
1755+
}
1756+
return false;
1757+
}
1758+
1759+
function insertSuggestionAtSelection() {
1760+
const selection = window.getSelection();
1761+
const range = selection.rangeCount ? selection.getRangeAt(0) : document.createRange();
1762+
if (!selection.rangeCount) {
1763+
range.selectNodeContents(editor);
1764+
range.collapse(false);
1765+
}
1766+
const suggestion = document.createElement('span');
1767+
suggestion.className = 'suggestion is-empty';
1768+
suggestion.textContent = '#';
1769+
range.deleteContents();
1770+
range.insertNode(suggestion);
1771+
range.setStartAfter(suggestion);
1772+
range.collapse(true);
1773+
selection.removeAllRanges();
1774+
selection.addRange(range);
1775+
}
1776+
1777+
let movedAfterFullText = false;
1778+
editor.addEventListener('input', () => {
1779+
if (movedAfterFullText || !(editor.textContent || '').includes('2. 第二条')) return;
1780+
movedAfterFullText = true;
1781+
queueMicrotask(() => placeCaretAfterText('1. 第一条'));
1782+
});
1783+
editor.addEventListener('click', () => {
1784+
if ((editor.textContent || '').includes('2. 第二条')) {
1785+
placeCaretAfterText('1. 第一条');
1786+
}
1787+
});
1788+
editor.addEventListener('beforeinput', (event) => {
1789+
if (event.inputType !== 'insertText' || !event.data) return;
1790+
const suggestion = editor.querySelector('.suggestion');
1791+
if (event.data === '#') {
1792+
event.preventDefault();
1793+
pendingTopic = '';
1794+
spaceCount = 0;
1795+
insertSuggestionAtSelection();
1796+
return;
1797+
}
1798+
if (suggestion && event.data !== ' ') {
1799+
event.preventDefault();
1800+
pendingTopic += event.data;
1801+
suggestion.className = 'suggestion';
1802+
suggestion.textContent = '#' + pendingTopic;
1803+
return;
1804+
}
1805+
if (suggestion && event.data === ' ') {
1806+
event.preventDefault();
1807+
}
1808+
});
1809+
editor.addEventListener('keyup', (event) => {
1810+
const suggestion = editor.querySelector('.suggestion');
1811+
if (!suggestion || event.code !== 'Space') return;
1812+
spaceCount++;
1813+
if (spaceCount < 2) return;
1814+
const data = JSON.stringify({id: 'topic-id', link: 'https://www.xiaohongshu.com/page/topics/topic-id?naviHidden=yes', name: pendingTopic});
1815+
const topic = document.createElement('a');
1816+
topic.className = 'tiptap-topic';
1817+
topic.setAttribute('data-topic', data);
1818+
topic.contentEditable = 'false';
1819+
topic.appendChild(document.createTextNode('#' + pendingTopic));
1820+
const hidden = document.createElement('span');
1821+
hidden.className = 'content-hide';
1822+
hidden.textContent = '[话题]#';
1823+
topic.appendChild(hidden);
1824+
suggestion.replaceWith(topic, document.createTextNode(' '));
1825+
});
1826+
</script>
1827+
</body>
1828+
</html>`
1829+
page.MustNavigate("data:text/html;charset=utf-8," + url.PathEscape(html))
1830+
page.MustWaitLoad()
1831+
page.MustElement("body")
1832+
1833+
rodPage := &rodPage{page: page}
1834+
if err := rodPage.FillContent(context.Background(), "1. 第一条\n2. 第二条", []string{"AI编程"}); err != nil {
1835+
t.Fatalf("FillContent() error = %v", err)
1836+
}
1837+
1838+
text := page.MustEval(`() => document.querySelector('.tiptap.ProseMirror')?.textContent || ''`).String()
1839+
if strings.Index(text, "#AI编程") < strings.Index(text, "2. 第二条") {
1840+
t.Fatalf("editor text = %q, want topic appended after existing text", text)
1841+
}
1842+
htmlOut := page.MustEval(`() => document.querySelector('.tiptap.ProseMirror')?.innerHTML || ''`).String()
1843+
firstIndex := strings.Index(htmlOut, "1. 第一条")
1844+
secondIndex := strings.Index(htmlOut, "2. 第二条")
1845+
breakIndex := strings.Index(htmlOut, "<br")
1846+
if firstIndex >= 0 && secondIndex >= 0 && breakIndex > firstIndex && breakIndex < secondIndex {
1847+
t.Fatalf("editor html = %q, want no empty paragraph between content lines", htmlOut)
1848+
}
1849+
}
1850+
17271851
func TestConfirmOnlySelfPublishedAcceptsPublishedRedirect(t *testing.T) {
17281852
page := testPage(t)
17291853

0 commit comments

Comments
 (0)