Skip to content

fix: preserve whitespace-only plain text paste - #4706

Merged
Jocs merged 2 commits into
marktext:developfrom
Renakoni:fix/whitespace-only-plain-text-paste
Jun 26, 2026
Merged

Jocs merged 2 commits into
marktext:developfrom
Renakoni:fix/whitespace-only-plain-text-paste

Conversation

@Renakoni

@Renakoni Renakoni commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Closes #4701

Summary

Preserve non-empty whitespace-only text/plain paste content when pasting inline into a paragraph.

Previously, a clipboard payload such as two ordinary spaces (U+0020 U+0020) was classified as text, then routed through the parsed Markdown paste path. That path intentionally treats empty or whitespace-only Markdown as a no-op to avoid creating empty block churn. As a result, pasting two spaces between A and B left the document unchanged:

AB

This PR keeps the existing parser guard for empty or block-like whitespace pastes, but routes non-empty inline whitespace from text/plain through literal insertion. Pasting two spaces at A|B now produces:

A  B

The fix is intentionally scoped to plain text, non-empty, no-newline whitespace so normal Markdown parsing for headings, lists, tables, HTML, and multiline paste remains unchanged.

Type of change

  • Bug fix (non-breaking, fixes an issue)
  • New feature (non-breaking, adds functionality)
  • Breaking change (causes existing functionality to change)
  • Documentation update

Test plan

  • New tests added (or explain why not needed)
  • Manually tested on: Not run for this patch; the regression is covered by focused clipboard unit tests.

The following checks passed:

pnpm --filter @muyajs/core test -- src/clipboard/__tests__/pasteHandlerParity.spec.ts
pnpm --filter @muyajs/core test -- src/clipboard/__tests__
pnpm --filter @muyajs/core exec vitest run --testTimeout 10000
pnpm --filter @muyajs/core lint:types
pnpm typecheck
pnpm --filter @muyajs/core lint
git diff --check upstream/develop..HEAD

Notes for reviewers [optional]

The root cause is in packages/muya/src/clipboard/paste.ts.

applyParsedPaste() intentionally returns early for whitespace-only Markdown:

if (markdown.trim().length === 0)
    return;

That guard is still needed for parsed/block paste behavior, but it also caught non-empty inline plain text consisting only of spaces. The new branch detects this narrower case before parsing:

const isPlainInlineWhitespace
    = copyType === 'text'
        && markdown.length > 0
        && markdown.trim().length === 0
        && !markdown.includes('\n');

When true, the paste uses the existing literal insertion path. This preserves spaces and keeps the caret at the expected offset without changing parsed Markdown behavior for other paste types.

Regression coverage adds a paste-handler test for AB with the cursor between A and B, pasting two plain spaces and expecting A B.


By submitting this pull request, I confirm that my contribution is made under the terms of the MIT license.

Copilot AI review requested due to automatic review settings June 25, 2026 00:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Jocs

Jocs commented Jun 25, 2026

Copy link
Copy Markdown
Member

Thanks for the fix! I verified it locally by driving the real paste path in a headless Chromium (the muya e2e harness: navigator.clipboard.write([ClipboardItem]) + a real Cmd/Ctrl+V, i.e. the same code path real users hit) against this branch's paste.ts.

TL;DR: the fix engages only when the clipboard carries text/plain alone. As soon as the clipboard also carries a text/html slot — which is what virtually every real "copy spaces from a browser / Word / another editor (and muya's own copy)" produces — the new branch is bypassed and the paste is still a no-op. That's almost certainly why it appears to "have no effect" when tested by hand.

Pasting two spaces at A|B:

Clipboard Result
text/plain: " " only A B ✅ (the case the new unit test covers)
text/html: <span> </span> + text/plain: " " AB ❌ no-op
realistic Chrome html (<meta> + styled span) + text/plain: " " AB ❌ no-op
text/html: <meta> (no real content) + text/plain: " " A B ✅ (only because normalize empties the html)

Root cause. getCopyTextType returns 'html' whenever html && text are both truthy:

// utils/paste.ts
if (pasteType === PasteType.NORMAL)
    return html && text ? 'html' : getTextType(text)

For two spaces, text is truthy and the normalized html is usually still non-empty, so copyType === 'html'. The new guard hard-requires copyType === 'text', so it never engages — the paste falls through to applyParsedPaste, whose markdown.trim().length === 0 early-return drops it.

Why CI stays green. The regression test feeds makePasteEvent({ 'text/plain': ' ' }) — no text/html slot — so it only exercises the one path that already works and never sees the multi-slot clipboard real users have.

Suggested fix. Base the decision on the intent ("a plain inline-whitespace paste"), not on copyType, and insert the original text (the html→markdown product may have dropped the spaces via turndown):

const isPlainInlineWhitespace
    = text.length > 0
        && text.trim().length === 0
        && !text.includes('\n')

if (isLiteralAnchor || isPlainInlineWhitespace)
    applyLiteralPaste(clipboard, ctx, isPlainInlineWhitespace ? text : markdown)
else
    applyParsedPaste(clipboard, ctx, markdown)

Adding a regression test that sets both text/html and text/plain would have caught this and would lock the real fix in place.

Minor, while here: …trim().length === 0 also treats \t / \xa0 as whitespace, so a lone tab routes to literal insertion as well; a leading literal tab can be re-read as an indented code block on the next markdown round-trip. Narrowing to actual spaces (e.g. /^ +$/.test(text)) sidesteps both this and the html issue above.

@Renakoni

Copy link
Copy Markdown
Contributor Author

Thanks, this review was right. I rechecked the real paste path and the previous fix was too narrow.

Root cause:

getCopyTextType(html, text, PasteType.NORMAL) returns html whenever both text/html and text/plain are present. Real sources such as browsers and editors usually put both formats on the clipboard, so the previous copyType === 'text' guard was bypassed. The paste then went through the HTML-to-Markdown path and finally reached applyParsedPaste(), where whitespace-only markdown is treated as empty and dropped.

Updated fix:

The special case now checks the original plain text payload instead of copyType or the HTML-derived markdown:

const isPlainInlineSpaces = /^ +$/.test(text);

When that is true, the paste uses literal insertion with the original text/plain value. This preserves spaces even when the clipboard also contains text/html. I intentionally narrowed this to ASCII spaces only, so tabs are not routed through this literal whitespace branch and cannot become an indented-code edge case on a later Markdown round trip.

Tests added:

  • A unit regression for text/html: '<span> </span>' plus text/plain: ' '.
  • A Chromium e2e regression that writes a real ClipboardItem with both text/html and text/plain, focuses the editor, and sends a real paste keystroke.

Validation:

pnpm --filter @muyajs/core test -- src/clipboard/__tests__
pnpm --filter @muyajs/core lint:types
pnpm --filter @muyajs/core lint
pnpm --filter muya-e2e e2e:chromium -- tests/editing/clipboard.spec.ts
pnpm --filter @muyajs/core exec vitest run --testTimeout 20000
pnpm typecheck
git diff --check

@Jocs
Jocs merged commit a8f6d03 into marktext:develop Jun 26, 2026
7 checks passed
hisaboh added a commit to hisaboh/remarks that referenced this pull request Jun 27, 2026
upstream が持ち込んだ主なもの:
- list serialization 刷新(空項目保持 marktext#4696/marktext#4697、markerOverride/_isLooseParentList)
- code fence 長の保持 marktext#4755、ソースモード undo/redo、math/diagram スクロール
- flush()/_flushOperationCache() による pending-edit 同期 marktext#2938
- plain-inline-spaces 保持の paste marktext#4706、大規模依存アップグレード marktext#4597 ほか

採用/保持の判断:
- フォーク保持: CLAUDE.md、setWrapCodeBlocks、PDF 描画
- 両立: fence info(フォーク) + fenceLength(upstream)、whole-line paste(フォーク) +
  plain-inline-spaces(upstream)、@tauri-apps/cli 保持 + upstream バージョンアップ採用
- upstream 採用: flush 機構(フォークの flushPendingChanges / _commitOperationCache /
  _rafHandle / _isGoing を除去、呼び出し側 editor.vue を flush() に更新)

波及修正:
- フォークの空 paragraph 往復保持を top-level (_listType 空) に限定し、upstream の
  空リスト項目処理と両立(muya unit 10件の回帰を解消)
- pnpm-lock.yaml をマージ後 package.json から再生成

検証(全緑): muya lint:types / check-circular / unit 1397 / conformance 1347、
desktop typecheck / unit 695、build:tauri 成功・実機動作確認 OK

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Pasting whitespace-only plain text into a paragraph is ignored

3 participants