Skip to content

Commit 21100b9

Browse files
committed
🐛(frontend) sanitize pasted and dropped content in document title
Prevent rich-text formatting when pasting or dropping into document title.
1 parent eaddbd8 commit 21100b9

3 files changed

Lines changed: 70 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- 🐛(frontend) sanitize pasted and dropped content in document title #2210
12+
913
## [v5.0.0] - 2026-04-08
1014

1115
### Added

src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,36 @@ test.describe('Doc Header', () => {
144144
await cleanup();
145145
});
146146

147+
test('it pastes plain text in the title without keeping formatting', async ({
148+
page,
149+
browserName,
150+
}) => {
151+
await createDoc(page, 'doc-title-paste', browserName, 1);
152+
153+
const docTitle = page.getByRole('textbox', { name: 'Document title' });
154+
await docTitle.click();
155+
await page.keyboard.press('Control+a');
156+
157+
await page.evaluate(() => {
158+
const el = document.querySelector('[aria-label="Document title"]');
159+
if (!el) {
160+
return;
161+
}
162+
163+
const dt = new DataTransfer();
164+
dt.setData('text/plain', 'Pasted plain text');
165+
dt.setData('text/html', '<b><em>Pasted plain text</em></b>');
166+
el.dispatchEvent(
167+
new ClipboardEvent('paste', { clipboardData: dt, bubbles: true }),
168+
);
169+
});
170+
171+
await docTitle.blur();
172+
await expect(docTitle).toHaveText('Pasted plain text');
173+
// Ensure formatting tags from text/html were not inserted.
174+
await expect(docTitle.locator('b, em, strong, i')).toHaveCount(0);
175+
});
176+
147177
test('it updates the title doc adding a leading emoji', async ({
148178
page,
149179
browserName,

src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,40 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
153153
}
154154
};
155155

156+
const insertPlainText = (plainText: string, target: HTMLElement) => {
157+
const selection = window.getSelection();
158+
if (!selection || selection.rangeCount === 0) {
159+
return;
160+
}
161+
162+
const range = selection.getRangeAt(0);
163+
if (!target.contains(range.commonAncestorContainer)) {
164+
target.focus();
165+
range.selectNodeContents(target);
166+
range.collapse(false);
167+
selection.removeAllRanges();
168+
selection.addRange(range);
169+
}
170+
range.deleteContents();
171+
range.insertNode(document.createTextNode(plainText));
172+
};
173+
174+
const handlePaste = (event: React.ClipboardEvent<HTMLSpanElement>) => {
175+
event.preventDefault();
176+
insertPlainText(
177+
event.clipboardData.getData('text/plain'),
178+
event.currentTarget,
179+
);
180+
};
181+
182+
const handleDrop = (event: React.DragEvent<HTMLSpanElement>) => {
183+
event.preventDefault();
184+
insertPlainText(
185+
event.dataTransfer.getData('text/plain'),
186+
event.currentTarget,
187+
);
188+
};
189+
156190
useEffect(() => {
157191
setTitleDisplay(isTopRoot ? doc.title : titleWithoutEmoji);
158192
}, [doc.title, isTopRoot, titleWithoutEmoji]);
@@ -181,6 +215,8 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
181215
onBlurCapture={(event) =>
182216
handleTitleSubmit(event.target.textContent || '')
183217
}
218+
onPasteCapture={handlePaste}
219+
onDropCapture={handleDrop}
184220
$padding={{ right: 'big' }}
185221
$css={css`
186222
&[contenteditable='true']:empty:not(:focus):before {

0 commit comments

Comments
 (0)